Compare commits

..

132 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
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
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
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
105 changed files with 76862 additions and 2321 deletions
+7
View File
@@ -6,3 +6,10 @@
/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
-->
+4
View File
@@ -22,10 +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
+3 -5
View File
@@ -11,13 +11,11 @@ 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@f29d1b6a8930683e80acedfbe6baa2930cd646b4 # 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
+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.86.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,5 +1,5 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# matrix-react-sdk playwright & cypress tests (with access to repo secrets)
# matrix-react-sdk playwright tests (with access to repo secrets)
name: matrix-react-sdk End to End Tests
on:
@@ -13,28 +13,6 @@ 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 multiple concurrent runners.
if: github.event.workflow_run.event == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.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
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST}}
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY}}
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
with:
react-sdk-repository: matrix-org/matrix-react-sdk
playwright:
name: Playwright
# We only want to run the playwright tests on merge queue to prevent regressions
@@ -49,15 +27,17 @@ jobs:
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'
@@ -65,18 +45,6 @@ jobs:
statuses: write
runs-on: ubuntu-latest
steps:
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: "${{ secrets.GITHUB_TOKEN }}"
state: success
description: Cypress skipped
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
# in matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml.
context: "${{ github.workflow }} / cypress"
sha: "${{ github.event.workflow_run.head_sha }}"
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: "${{ secrets.GITHUB_TOKEN }}"
+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 }}
+12 -4
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
@@ -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,7 +56,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Add label
if: ${{ steps.teams.outputs.isTeamMember == 'false' }}
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@v7
with:
script: |
@@ -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
+62 -84
View File
@@ -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,18 +86,12 @@ 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
@@ -132,76 +124,23 @@ jobs:
- 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#v}"
- name: Ingest upstream changes
if: |
inputs.include-changes &&
(!inputs.dependencies || contains(fromJSON(steps.update-dependencies.outputs.updated), inputs.include-changes))
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
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);
- name: Add to CHANGELOG.md
if: inputs.final
run: |
@@ -219,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: |
@@ -237,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
@@ -252,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.
@@ -273,7 +214,7 @@ jobs:
if: inputs.expected-asset-count
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
@@ -301,7 +242,7 @@ jobs:
- name: Publish release
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
@@ -335,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 }}
@@ -351,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
+2
View File
@@ -28,6 +28,7 @@ jobs:
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: |
+42 -38
View File
@@ -5,6 +5,8 @@ on:
secrets:
SONAR_TOKEN:
required: true
ELEMENT_BOT_TOKEN:
required: true
inputs:
sharded:
type: boolean
@@ -15,45 +17,14 @@ on:
required: false
description: "Extra args to pass to SonarCloud"
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' &&
inputs.sharded
runs-on: ubuntu-latest
outputs:
extra_args: |
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
-Dsonar.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@f29d1b6a8930683e80acedfbe6baa2930cd646b4 # 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:
runs-on: ubuntu-latest
needs: prepare
if: |
always() && !failure() && !cancelled() &&
github.event.workflow_run.conclusion == 'success'
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.
# 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 }}
@@ -62,22 +33,55 @@ 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.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 }} ${{ needs.prepare.outputs.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@071b5370da85afbb16637d6eed8524a06bc2053e # v1
if: always()
+1
View File
@@ -14,5 +14,6 @@ jobs:
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:
sharded: true
+1 -1
View File
@@ -91,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
+2 -2
View File
@@ -56,9 +56,9 @@ jobs:
- 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
+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 }}
-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
+66
View File
@@ -1,3 +1,68 @@
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
@@ -76,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)).
-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/)
+15 -14
View File
@@ -1,12 +1,12 @@
{
"name": "matrix-js-sdk",
"version": "31.1.0",
"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",
@@ -52,15 +52,15 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^3.5.0",
"@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",
@@ -83,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",
@@ -91,37 +92,37 @@
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "9",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"allchange": "^1.0.6",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.55.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",
"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": "3.1.1",
"node-fetch": "^2.7.0",
"prettier": "3.2.5",
"rimraf": "^5.0.0",
"ts-node": "^10.9.1",
"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",
-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
+104 -44
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,25 +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_PREFIX)) {
heading = line.trim();
sections.set(heading, []);
continue;
}
if (heading && line) {
sections.get(heading).push(line.trim());
}
const releases = await getReleases(github, dependency);
for (const release of releases) {
parseReleaseNotes(release.body, sections);
}
}
@@ -52,36 +126,22 @@ const main = async ({ github, releaseId, dependencies }) => {
release_id: releaseId,
});
const headings = ["🚨 BREAKING CHANGES", "🦖 Deprecations", "✨ Features", "🐛 Bug Fixes", "🧰 Maintenance"].map(
(h) => HEADING_PREFIX + h,
);
const intro = release.body.split(HEADING_PREFIX, 2)[0].trim();
heading = null;
const output = [];
for (const line of [...release.body.split("\n"), null]) {
if (line === null || line.startsWith(HEADING_PREFIX)) {
// If we have a heading, and it's not the first in the list of pending headings, output the section.
// If we're processing the last line (null) then output all remaining sections.
while (headings.length > 0 && (line === null || (heading && headings[0] !== heading))) {
const heading = headings.shift();
if (sections.has(heading)) {
output.push(heading);
output.push(...sections.get(heading));
}
}
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);
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
+2 -1
View File
@@ -398,7 +398,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
describe("crossSignDevice", () => {
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"] });
// make sure that there is another device which we can sign
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
+197 -17
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";
@@ -230,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;
@@ -368,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 */
@@ -701,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");
@@ -732,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");
@@ -760,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");
@@ -1063,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"]);
@@ -1378,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();
@@ -1394,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();
@@ -2069,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");
@@ -2092,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");
@@ -2189,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(() => {
@@ -2389,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();
@@ -2745,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(() => {
@@ -2899,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);
@@ -2935,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/,
);
}
});
});
+192 -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, IEvent, 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,7 +41,7 @@ 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";
@@ -119,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);
@@ -286,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]: {
@@ -340,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,
@@ -371,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]: {
+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", () => {
+8 -5
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 () => {
@@ -1285,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();
@@ -1309,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();
@@ -1334,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();
@@ -1355,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();
@@ -1555,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,
+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);
});
}
}
+1 -98
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
@@ -409,10 +406,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -450,10 +443,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -476,9 +465,6 @@ describe("AutoDiscovery", function () {
// 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);
@@ -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);
@@ -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.5"] });
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("");
});
@@ -74,6 +74,12 @@ describe.each([
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);
@@ -145,6 +151,13 @@ describe.each([
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++) {
@@ -153,6 +166,9 @@ describe.each([
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);
}
}
});
@@ -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);
});
});
});
+6 -4
View File
@@ -189,10 +189,12 @@ describe("SAS verification", function () {
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = async (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map.get(alice.client.getUserId()!)?.get(alice.client.deviceId!)
?.message_authentication_code;
keyAgreement = map.get(alice.client.getUserId()!)?.get(alice.client.deviceId!)
?.key_agreement_protocol;
macMethod = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.message_authentication_code;
keyAgreement = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.key_agreement_protocol;
}
return origSendToDevice(type, map);
};
+54 -24
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", () => {
@@ -3000,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);
});
});
});
+24 -3
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);
+22 -3
View File
@@ -74,6 +74,13 @@ describe("MatrixRTCSession", () => {
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;
@@ -91,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),
@@ -112,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),
@@ -214,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,
@@ -227,6 +240,7 @@ describe("MatrixRTCSession", () => {
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [{ type: "mock" }],
membershipID: expect.stringMatching(".*"),
},
@@ -234,6 +248,7 @@ describe("MatrixRTCSession", () => {
},
"@alice:example.org",
);
jest.useRealTimers();
});
it("does nothing if join called when already joined", () => {
@@ -291,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(".*"),
@@ -510,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);
@@ -591,6 +607,7 @@ describe("MatrixRTCSession", () => {
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
@@ -605,7 +622,7 @@ describe("MatrixRTCSession", () => {
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();
});
});
+3
View File
@@ -24,6 +24,7 @@ export function makeMockRoom(memberships: CallMembershipData[], localAge: number
const roomState = makeMockRoomState(memberships, roomId, localAge);
return {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(roomState),
}),
@@ -33,6 +34,8 @@ export function makeMockRoom(memberships: CallMembershipData[], localAge: number
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) => {
if (stateKey !== undefined) return event;
return [event];
+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);
+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/);
+4 -4
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:
+11 -3
View File
@@ -97,7 +97,10 @@ describe("KeyClaimManager", () => {
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" },
@@ -135,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);
@@ -147,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();
});
});
});
});
+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);
});
});
+387 -17
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,11 +41,12 @@ 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 {
AccountDataClient,
@@ -59,10 +69,18 @@ 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();
@@ -73,16 +91,23 @@ 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({
logger,
@@ -95,17 +120,16 @@ describe("initRustCrypto", () => {
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({
logger,
@@ -118,12 +142,16 @@ describe("initRustCrypto", () => {
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);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto({
logger,
@@ -138,6 +166,212 @@ describe("initRustCrypto", () => {
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", () => {
@@ -149,7 +383,7 @@ describe("RustCrypto", () => {
);
});
describe(".importRoomKeys and .exportRoomKeys", () => {
describe("importing and exporting room keys", () => {
let rustCrypto: RustCrypto;
beforeEach(
@@ -165,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);
@@ -182,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", () => {
@@ -463,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", () => {
@@ -821,7 +1130,8 @@ 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"] });
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
@@ -846,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();
@@ -1026,6 +1363,34 @@ describe("RustCrypto", () => {
},
});
});
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,
});
});
});
});
@@ -1095,3 +1460,8 @@ class DummyAccountDataClient
return {};
}
}
/** Pad a string to 43 characters long */
function pad43(x: string): string {
return x + ".".repeat(43 - x.length);
}
+506 -2
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", () => {
@@ -91,6 +102,354 @@ describe("VerificationRequest", () => {
);
});
});
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", () => {
@@ -152,3 +511,148 @@ function makeMockedInner(): Mocked<RustSdkCryptoJs.VerificationRequest> {
},
} 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;
},
};
}
+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[]];
+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
+1 -125
View File
@@ -15,19 +15,9 @@ 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 { SUPPORTED_MATRIX_VERSIONS } from "./version-support";
// Dev note: Auto discovery is part of the spec.
@@ -65,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;
}
/**
@@ -318,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
+283 -124
View File
@@ -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";
@@ -625,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> {
@@ -645,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?: {
@@ -962,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;
@@ -1304,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;
@@ -1444,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 =
@@ -2328,7 +2324,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
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;
@@ -2347,6 +2349,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
CryptoEvent.KeyBackupSessionsRemaining,
CryptoEvent.KeyBackupFailed,
CryptoEvent.KeyBackupDecryptionKeyCached,
CryptoEvent.KeysChanged,
CryptoEvent.DevicesUpdated,
CryptoEvent.WillUpdateDevices,
]);
}
@@ -3238,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) {
@@ -3250,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);
@@ -3261,8 +3272,7 @@ 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;
}
@@ -3898,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);
@@ -3934,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 {
@@ -3961,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);
}
@@ -3970,15 +4022,88 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
backupDecryptor.free();
}
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
progressCallback,
untrusted,
});
/// 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>;
@@ -4327,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);
@@ -4628,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) {
@@ -4731,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;
}
/**
@@ -4753,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 {
@@ -5683,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(
@@ -5691,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);
}
/**
@@ -9818,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",
});
}
}
/**
+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) {
+42 -6
View File
@@ -53,6 +53,18 @@ export interface CryptoApi {
*/
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.
@@ -84,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
*
@@ -93,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.
*
@@ -189,7 +223,7 @@ export interface CryptoApi {
* 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}.
* 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!
*
@@ -581,18 +615,20 @@ 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. */
+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. */
+41
View File
@@ -259,6 +259,15 @@ export enum CryptoEvent {
WillUpdateDevices = "crypto.willUpdateDevices",
DevicesUpdated = "crypto.devicesUpdated",
KeysChanged = "crossSigning.keysChanged",
/**
* Fires when data is being migrated from legacy crypto to rust crypto.
*
* The payload is a pair `(progress, total)`, where `progress` is the number of steps completed so far, and
* `total` is the total number of steps. When migration is complete, a final instance of the event is emitted, with
* `progress === total === -1`.
*/
LegacyCryptoStoreMigrationProgress = "crypto.legacyCryptoStoreMigrationProgress",
}
export type CryptoEventHandlerMap = {
@@ -368,6 +377,8 @@ export type CryptoEventHandlerMap = {
*/
[CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
[CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void;
[CryptoEvent.LegacyCryptoStoreMigrationProgress]: (progress: number, total: number) => void;
};
export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap> implements CryptoBackend {
@@ -3131,6 +3142,16 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return exportedSessions;
}
/**
* Get a JSON list containing all of the room keys
*
* @returns a JSON string encoding a list of session
* export objects, each of which is an IMegolmSessionData
*/
public async exportRoomKeysAsJson(): Promise<string> {
return JSON.stringify(await this.exportRoomKeys());
}
/**
* Import a list of room keys previously exported by exportRoomKeys
*
@@ -3173,6 +3194,19 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
).then();
}
/**
* 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
*/
public async importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise<void> {
return await this.importRoomKeys(JSON.parse(keys));
}
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns Promise which resolves to the number of sessions requiring backup
@@ -4262,6 +4296,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.roomList.isRoomEncrypted(roomId);
}
/**
* Implementation of {@link CryptoApi#isEncryptionEnabledInRoom}.
*/
public async isEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
return this.isRoomEncrypted(roomId);
}
/**
* @returns information about the encryption on the room with the supplied
* ID, or null if the room is not encrypted or unknown to us.
+3 -1
View File
@@ -15,6 +15,8 @@ limitations under the License.
*/
// Export for backward compatibility
import { ImportRoomKeyProgressData } from "../crypto-api";
export type {
Curve25519AuthData as ICurve25519AuthData,
Aes256AuthData as IAes256AuthData,
@@ -41,5 +43,5 @@ export interface IKeyBackupRestoreResult {
export interface IKeyBackupRestoreOpts {
cacheCompleteCallback?: () => void;
progressCallback?: (progress: { stage: string }) => void;
progressCallback?: (progress: ImportRoomKeyProgressData) => void;
}
+27 -1
View File
@@ -176,13 +176,20 @@ export interface CryptoStore {
txn: unknown,
): void;
/**
* Count the number of Megolm sessions in the database.
*
* @internal
*/
countEndToEndInboundGroupSessions(): Promise<number>;
/**
* Get a batch of Megolm sessions from the database.
*
* @returns A batch of Megolm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndInboundGroupSessionsBatch(): Promise<ISession[] | null>;
getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null>;
/**
* Delete a batch of Megolm sessions from the database.
@@ -223,6 +230,11 @@ export interface ISession {
sessionData?: InboundGroupSessionData;
}
/** Extended data on a Megolm session */
export interface SessionExtended extends ISession {
needsBackup: boolean;
}
/** Data on an Olm session */
export interface ISessionInfo {
deviceKey?: string;
@@ -294,6 +306,13 @@ export interface ParkedSharedHistory {
forwardingCurve25519KeyChain: string[];
}
/**
* Keys for the `account` object store to store the migration state.
* Values are defined in `MigrationState`.
* @internal
*/
export const ACCOUNT_OBJECT_KEY_MIGRATION_STATE = "migrationState";
/**
* A record of which steps have been completed in the libolm to Rust Crypto migration.
*
@@ -313,6 +332,13 @@ export enum MigrationState {
/** OLM_SESSIONS_MIGRATED, and in addition, we have migrated all the Megolm sessions. */
MEGOLM_SESSIONS_MIGRATED,
/** MEGOLM_SESSIONS_MIGRATED, and in addition, we have migrated all the room settings. */
ROOM_SETTINGS_MIGRATED,
/** ROOM_SETTINGS_MIGRATED, and in addition, we have done the first own keys query in order to
* load the public part of the keys that have been migrated */
INITIAL_OWN_KEY_QUERY_DONE,
}
/**
@@ -21,6 +21,7 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
@@ -29,6 +30,7 @@ import {
ParkedSharedHistory,
SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -39,9 +41,6 @@ import { IndexedDBCryptoStore } from "./indexeddb-crypto-store";
const PROFILE_TRANSACTIONS = false;
/* Keys for the `account` object store */
const ACCOUNT_OBJECT_KEY_MIGRATION_STATE = "migrationState";
/**
* Implementation of a CryptoStore which is backed by an existing
* IndexedDB connection. Generally you want IndexedDBCryptoStore
@@ -810,34 +809,63 @@ export class Backend implements CryptoStore {
});
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let result = 0;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const countReq = sessionStore.count();
countReq.onsuccess = (): void => {
result = countReq.result;
};
});
return result;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | ISession[]> {
const result: ISession[] = [];
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
result.push({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
});
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
await this.doTxn(
"readonly",
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
(txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const backupStore = txn.objectStore(IndexedDBCryptoStore.STORE_BACKUP);
const getReq = sessionStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
const backupGetReq = backupStore.get(cursor.key);
backupGetReq.onsuccess = (): void => {
result.push({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
needsBackup: backupGetReq.result !== undefined,
});
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
};
}
} catch (e) {
abortWithException(txn, <Error>e);
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
});
};
},
);
if (result.length === 0) {
// No sessions left.
+60 -1
View File
@@ -25,6 +25,7 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
@@ -32,6 +33,7 @@ import {
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -62,6 +64,52 @@ export class IndexedDBCryptoStore implements CryptoStore {
return IndexedDBHelpers.exists(indexedDB, dbName);
}
/**
* Utility to check if a legacy crypto store exists and has not been migrated.
* Returns true if the store exists and has not been migrated, false otherwise.
*/
public static existsAndIsNotMigrated(indexedDb: IDBFactory, dbName: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
let exists = true;
const openDBRequest = indexedDb.open(dbName);
openDBRequest.onupgradeneeded = (): void => {
// Since we did not provide an explicit version when opening, this event
// should only fire if the DB did not exist before at any version.
exists = false;
};
openDBRequest.onblocked = (): void => reject(openDBRequest.error);
openDBRequest.onsuccess = (): void => {
const db = openDBRequest.result;
if (!exists) {
db.close();
// The DB did not exist before, but has been created as part of this
// existence check. Delete it now to restore previous state. Delete can
// actually take a while to complete in some browsers, so don't wait for
// it. This won't block future open calls that a store might issue next to
// properly set up the DB.
indexedDb.deleteDatabase(dbName);
resolve(false);
} else {
const tx = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readonly");
const objectStore = tx.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
const migrationState = getReq.result ?? MigrationState.NOT_STARTED;
resolve(migrationState === MigrationState.NOT_STARTED);
};
getReq.onerror = (): void => {
reject(getReq.error);
};
db.close();
}
};
openDBRequest.onerror = (): void => reject(openDBRequest.error);
});
}
private backendPromise?: Promise<CryptoStore>;
private backend?: CryptoStore;
@@ -505,6 +553,17 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.filterOutNotifiedErrorDevices(devices);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public countEndToEndInboundGroupSessions(): Promise<number> {
return this.backend!.countEndToEndInboundGroupSessions();
}
/**
* Fetch a batch of Olm sessions from the database.
*
@@ -610,7 +669,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
*
* @internal
*/
public getEndToEndInboundGroupSessionsBatch(): Promise<ISession[] | null> {
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
return this.backend!.getEndToEndInboundGroupSessionsBatch();
}
+32 -5
View File
@@ -21,6 +21,7 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
@@ -126,7 +127,11 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count;
const key = this.store.key(i);
if (key?.startsWith(keyEndToEndSessions(""))) {
const sessions = getJsonItem(this.store, key);
count += Object.keys(sessions ?? {}).length;
}
}
func(count);
}
@@ -350,6 +355,24 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
count += 1;
}
}
return count;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
@@ -357,20 +380,24 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<ISession[] | null> {
const result: ISession[] = [];
public async getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const result: SessionExtended[] = [];
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
const key2 = key.slice(KEY_INBOUND_SESSION_PREFIX.length);
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
result.push({
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
senderKey: key2.slice(0, 43),
sessionId: key2.slice(44),
sessionData: getJsonItem(this.store, key)!,
needsBackup: key2 in sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
+20 -3
View File
@@ -21,6 +21,7 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
@@ -335,7 +336,11 @@ export class MemoryCryptoStore implements CryptoStore {
// Olm Sessions
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
func(Object.keys(this.sessions).length);
let count = 0;
for (const deviceSessions of Object.values(this.sessions)) {
count += Object.keys(deviceSessions).length;
}
func(count);
}
public getEndToEndSession(
@@ -527,6 +532,17 @@ export class MemoryCryptoStore implements CryptoStore {
this.inboundGroupSessionsWithheld[k] = sessionData;
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
return Object.keys(this.inboundGroupSessions).length;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
@@ -534,13 +550,14 @@ export class MemoryCryptoStore implements CryptoStore {
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | ISession[]> {
const result: ISession[] = [];
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
result.push({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
sessionData: session,
needsBackup: key in this.sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
+46
View File
@@ -151,3 +151,49 @@ export async function retryNetworkOperation<T>(maxAttempts: number, callback: ()
}
throw lastConnectionError;
}
/**
* Calculate the backoff time for a request retry attempt.
* This produces wait times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
* failure was due to a rate limited request, the time specified in the error is returned.
*
* Returns -1 if the error is not retryable, or if we reach the maximum number of attempts.
*
* @param err - The error thrown by the http call
* @param attempts - The number of attempts made so far, including the one that just failed.
* @param retryConnectionError - Whether to retry on {@link ConnectionError} (CORS, connection is down, etc.)
*/
export function calculateRetryBackoff(err: any, attempts: number, retryConnectionError: boolean): number {
if (attempts > 4) {
return -1; // give up
}
if (err instanceof ConnectionError && !retryConnectionError) {
return -1;
}
if (err.httpStatus && (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401)) {
// client error; no amount of retrying will save you now.
return -1;
}
if (err.name === "AbortError") {
// this is a client timeout, that is already very high 60s/80s
// we don't want to retry, as it could do it for very long
return -1;
}
// If we are trying to send an event (or similar) that is too large in any way, then retrying won't help
if (err.name === "M_TOO_LARGE") {
return -1;
}
if (err.name === "M_LIMIT_EXCEEDED") {
const waitTime = err.data.retry_after_ms;
if (waitTime > 0) {
return waitTime;
}
}
return 1000 * Math.pow(2, attempts);
}
+1
View File
@@ -56,6 +56,7 @@ export * from "./crypto/store/localStorage-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store";
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
export * from "./content-repo";
export * from "./@types/common";
export * from "./@types/uia";
export * from "./@types/event";
export * from "./@types/PushRules";
+31 -6
View File
@@ -27,7 +27,8 @@ export interface CallMembershipData {
scope: CallScope;
device_id: string;
created_ts?: number;
expires: number;
expires?: number;
expires_ts?: number;
foci_active?: Focus[];
membershipID: string;
}
@@ -41,7 +42,20 @@ export class CallMembership {
private parentEvent: MatrixEvent,
private data: CallMembershipData,
) {
if (typeof data.expires !== "number") throw new Error("Malformed membership: expires must be numeric");
if (!(data.expires || data.expires_ts)) {
throw new Error("Malformed membership: expires_ts or expires must be present");
}
if (data.expires) {
if (typeof data.expires !== "number") {
throw new Error("Malformed membership: expires must be numeric");
}
}
if (data.expires_ts) {
if (typeof data.expires_ts !== "number") {
throw new Error("Malformed membership: expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
@@ -77,16 +91,27 @@ export class CallMembership {
}
public getAbsoluteExpiry(): number {
return this.createdTs() + this.data.expires;
if (this.data.expires) {
return this.createdTs() + this.data.expires;
} else {
// We know it exists because we checked for this in the constructor.
return this.data.expires_ts!;
}
}
// gets the expiry time of the event, converted into the device's local time
public getLocalExpiry(): number {
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
if (this.data.expires) {
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
return localCreationTs + this.data.expires;
return localCreationTs + this.data.expires;
} else {
// With expires_ts we cannot convert to local time.
// TODO: Check the server timestamp and compute a diff to local time.
return this.data.expires_ts!;
}
}
public getMsUntilExpiry(): number {
+16 -6
View File
@@ -21,6 +21,7 @@ import { Room } from "../models/room";
import { MatrixClient } from "../client";
import { EventType } from "../@types/event";
import { CallMembership, CallMembershipData } from "./CallMembership";
import { RoomStateEvent } from "../models/room-state";
import { Focus } from "./focus";
import { MatrixError, MatrixEvent } from "../matrix";
import { randomString, secureRandomBase64Url } from "../randomstring";
@@ -133,7 +134,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
if (eventMemberships === undefined) {
logger.warn(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
logger.debug(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
continue;
}
if (!Array.isArray(eventMemberships)) {
@@ -152,9 +153,11 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}
if (membership.isExpired()) {
logger.info(
`Ignoring expired device membership ${memberEvent.getSender()}/${membership.deviceId}`,
);
logger.info(`Ignoring expired device membership ${membership.sender}/${membership.deviceId}`);
continue;
}
if (!room.hasMembershipState(membership.sender ?? "", "join")) {
logger.info(`Ignoring membership of user ${membership.sender} who is not in the room.`);
continue;
}
callMemberships.push(membership);
@@ -176,7 +179,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}
/**
* Return a the MatrixRTC for the room, whether there are currently active members or not
* Return the MatrixRTC session for the room, whether there are currently active members or not
*/
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
const callMemberships = MatrixRTCSession.callMembershipsForRoom(room);
@@ -191,6 +194,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
) {
super();
this._callId = memberships[0]?.callId;
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
roomState?.on(RoomStateEvent.Members, this.onMembershipUpdate);
this.setExpiryTimer();
}
@@ -214,6 +219,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
roomState?.off(RoomStateEvent.Members, this.onMembershipUpdate);
}
/**
@@ -506,7 +513,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
return;
}
// We currently only handle callId = ""
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
@@ -624,6 +631,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
};
if (prevMembership) m.created_ts = prevMembership.createdTs();
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
// TODO: Date.now() should be the origin_server_ts (now).
else m.expires_ts = Date.now() + (m.expires ?? 0);
return m;
}
+7 -3
View File
@@ -98,16 +98,20 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
return this.roomSessions.get(room.roomId)!;
}
private onTimeline = (event: MatrixEvent): void => {
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return;
private async consumeCallEncryptionEvent(event: MatrixEvent): Promise<void> {
await this.client.decryptEventIfNeeded(event);
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
const room = this.client.getRoom(event.getRoomId());
if (!room) {
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return;
return Promise.resolve();
}
this.getRoomSession(room).onCallEncryption(event);
}
private onTimeline = (event: MatrixEvent): void => {
this.consumeCallEncryptionEvent(event);
};
private onRoom = (room: Room): void => {
+3 -2
View File
@@ -1003,8 +1003,9 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
if (!shouldLiveInRoom && !shouldLiveInThread) {
logger.warn(
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${this.room
?.roomId} eventId=${event.getId()} threadId=${event.threadRootId}`,
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${
this.room?.roomId
} eventId=${event.getId()} threadId=${event.threadRootId}`,
);
}
+5 -1
View File
@@ -163,6 +163,10 @@ export interface IVisibilityChange {
reason: string | null;
}
export interface IMarkedUnreadEvent {
unread: boolean;
}
export interface IClearEvent {
room_id?: string;
type: string;
@@ -350,7 +354,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* most recent error associated with sending the event, if any
* @privateRemarks
* Should be read-only
* Should be read-only. May not be a MatrixError.
*/
public error: MatrixError | null = null;
/**
+1 -1
View File
@@ -79,7 +79,7 @@ export abstract class ReadReceipt<
private receiptCacheByEventId: ReceiptCache = new Map();
public abstract getUnfilteredTimelineSet(): EventTimelineSet;
public abstract timeline: MatrixEvent[];
public abstract get timeline(): MatrixEvent[];
/**
* Gets the latest receipt for a given user in the room
+38 -15
View File
@@ -381,13 +381,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* The room summary.
*/
public summary: RoomSummary | null = null;
/**
* The live event timeline for this room, with the oldest event at index 0.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getEvents() instead
*/
public timeline!: MatrixEvent[];
/**
* oldState The state of the room at the time of the oldest event in the live timeline.
*
@@ -793,6 +786,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.getUnfilteredTimelineSet().getLiveTimeline();
}
/**
* The live event timeline for this room, with the oldest event at index 0.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getEvents() instead
*/
public get timeline(): MatrixEvent[] {
return this.getLiveTimeline().getEvents();
}
/**
* Get the timestamp of the last message in the room
*
@@ -980,7 +983,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// that this function is only called once (unless loading the members
// fails), since loadMembersIfNeeded always returns this.membersPromise
// if set, which will be the result of the first (successful) call.
if (rawMembersEvents === null || (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) {
if (rawMembersEvents === null || this.hasEncryptionStateEvent()) {
fromServer = true;
rawMembersEvents = await this.loadMembersFromServer();
logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`);
@@ -1221,11 +1224,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const previousOldState = this.oldState;
const previousCurrentState = this.currentState;
// maintain this.timeline as a reference to the live timeline,
// and this.oldState and this.currentState as references to the
// maintain this.oldState and this.currentState as references to the
// state at the start and end of that timeline. These are more
// for backwards-compatibility than anything else.
this.timeline = this.getLiveTimeline().getEvents();
this.oldState = this.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
this.currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
@@ -1275,9 +1276,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* error will be thrown.
*
* @returns the result
*
* @deprecated Not supported under rust crypto. Instead, call {@link Room.getEncryptionTargetMembers},
* {@link CryptoApi.getUserDeviceInfo}, and {@link CryptoApi.getDeviceVerificationStatus}.
*/
public async hasUnverifiedDevices(): Promise<boolean> {
if (!this.client.isRoomEncrypted(this.roomId)) {
if (!this.hasEncryptionStateEvent()) {
return false;
}
const e2eMembers = await this.getEncryptionTargetMembers();
@@ -1979,6 +1983,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
this.on(ThreadEvent.NewReply, this.onThreadReply);
this.on(ThreadEvent.Update, this.onThreadUpdate);
this.on(ThreadEvent.Delete, this.onThreadDelete);
this.threadsReady = true;
}
@@ -2082,6 +2087,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
private onThreadUpdate(thread: Thread): void {
this.updateThreadRootEvents(thread, false, false);
}
private onThreadReply(thread: Thread): void {
this.updateThreadRootEvents(thread, false, true);
}
@@ -2329,7 +2338,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.lastThread = thread;
}
if (this.threadsReady) {
// We need to update the thread root events, but the thread may not be ready yet.
// If it isn't, it will fire ThreadEvent.Update when it is and we'll call updateThreadRootEvents then.
if (this.threadsReady && thread.initialEventsFetched) {
this.updateThreadRootEvents(thread, toStartOfTimeline, false);
}
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
@@ -2558,7 +2569,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
.filter((event) => {
// Filter out the unencrypted messages if the room is encrypted
const isEventEncrypted = event.type === EventType.RoomMessageEncrypted;
const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId);
const isRoomEncrypted = this.hasEncryptionStateEvent();
return isEventEncrypted || !isRoomEncrypted;
});
@@ -3163,7 +3174,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public maySendMessage(): boolean {
return (
this.getMyMembership() === "join" &&
(this.client.isRoomEncrypted(this.roomId)
(this.hasEncryptionStateEvent()
? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId)
: this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId))
);
@@ -3665,6 +3676,18 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public compareEventOrdering(leftEventId: string, rightEventId: string): number | null {
return compareEventOrdering(this, leftEventId, rightEventId);
}
/**
* Return true if this room has an `m.room.encryption` state event.
*
* If this returns `true`, events sent to this room should be encrypted (and `MatrixClient.sendEvent` and friends
* will encrypt outgoing events).
*/
public hasEncryptionStateEvent(): boolean {
return Boolean(
this.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(EventType.RoomEncryption, ""),
);
}
}
// a map from current event status to a list of allowed next statuses
+73 -59
View File
@@ -84,7 +84,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* A reference to all the events ID at the bottom of the threads
*/
public readonly timelineSet: EventTimelineSet;
public timeline: MatrixEvent[] = [];
private _currentUserParticipated = false;
@@ -133,7 +132,13 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
private readonly pendingEventOrdering: PendingEventOrdering;
private processRootEventPromise?: Promise<void>;
/**
* Whether or not we need to fetch the initial set of events for the thread. We can
* only do this if the server has support for it, so if it doesn't we just pretend
* that we've already fetched them.
*/
public initialEventsFetched = !Thread.hasServerSideSupport;
/**
* An array of events to add to the timeline once the thread has been initialised
* with server suppport.
@@ -193,8 +198,8 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
};
private async fetchRootEvent(): Promise<void> {
this.rootEvent = this.room.findEventById(this.id);
// If the rootEvent does not exist in the local stores, then fetch it from the server.
// Always fetch the root event, even if we already have it, so we can get the latest
// state (via unsigned).
try {
const eventData = await this.client.fetchRoomEvent(this.roomId, this.id);
const mapper = this.client.getEventMapper();
@@ -317,7 +322,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
fromCache: false,
roomState: this.roomState,
});
this.timeline = this.events;
}
}
@@ -344,9 +348,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
return;
}
this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState);
// As far as we know, timeline should always be the same as events
this.timeline = this.events;
}
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
@@ -363,7 +364,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* to the start (and not the end) of the timeline.
* @param emit - whether to emit the Update event if the thread was updated or not.
*/
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void {
// Modify this event to point at our room's state, and mark its thread
// as this.
this.setEventMetadata(event);
@@ -382,56 +383,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
// If this event is not a direct member of the thread, but is a
// reference to something that is, then we have two cases:
if (!this.initialEventsFetched) {
// Case 1: we haven't yet fetched events from the server. In
// this case, when we do, the events we get back might only be
// the first-order ones, so this event (which is second-order -
// a reference to something directly in the thread) needs to be
// kept so we can replay it when the first-order ones turn up.
/**
* A thread can be fully discovered via a single sync response
* And when that's the case we still ask the server to do an initialisation
* as it's the safest to ensure we have everything.
* However when we are in that scenario we might loose annotation or edits
*
* This fix keeps a reference to those events and replay them once the thread
* has been initialised properly.
*/
this.replayEvents?.push(event);
} else {
// Case 2: this is happening later, and we have a timeline. In
// this case, these events might be out-of order.
//
// Specifically, if the server doesn't support recursion, so we
// only get these events through sync, they might be coming
// later than the first-order ones, so we insert them based on
// timestamp (despite the problems with this documented in
// #3325).
//
// If the server does support recursion, we should have got all
// the interspersed events from the server when we fetched the
// initial events, so if they are coming via sync they should be
// the latest ones, so we can add them as normal.
//
// (Note that both insertEventIntoTimeline and addEventToTimeline
// do nothing if we have seen this event before.)
const recursionSupport =
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport === ServerSupport.Unsupported) {
this.insertEventIntoTimeline(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
}
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
this.addRelatedThreadEvent(event, toStartOfTimeline);
return;
} else if (this.initialEventsFetched) {
// If initial events have not been fetched, we are OK to throw away
@@ -468,12 +420,64 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
}
}
private addRelatedThreadEvent(event: MatrixEvent, toStartOfTimeline: boolean): void {
// If this event is not a direct member of the thread, but is a
// reference to something that is, then we have two cases:
if (!this.initialEventsFetched) {
// Case 1: we haven't yet fetched events from the server. In
// this case, when we do, the events we get back might only be
// the first-order ones, so this event (which is second-order -
// a reference to something directly in the thread) needs to be
// kept so we can replay it when the first-order ones turn up.
/**
* A thread can be fully discovered via a single sync response
* And when that's the case we still ask the server to do an initialisation
* as it's the safest to ensure we have everything.
* However when we are in that scenario we might loose annotation or edits
*
* This fix keeps a reference to those events and replay them once the thread
* has been initialised properly.
*/
this.replayEvents?.push(event);
} else {
// Case 2: this is happening later, and we have a timeline. In
// this case, these events might be out-of order.
//
// Specifically, if the server doesn't support recursion, so we
// only get these events through sync, they might be coming
// later than the first-order ones, so we insert them based on
// timestamp (despite the problems with this documented in
// #3325).
//
// If the server does support recursion, we should have got all
// the interspersed events from the server when we fetched the
// initial events, so if they are coming via sync they should be
// the latest ones, so we can add them as normal.
//
// (Note that both insertEventIntoTimeline and addEventToTimeline
// do nothing if we have seen this event before.)
const recursionSupport =
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport === ServerSupport.Unsupported) {
this.insertEventIntoTimeline(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
}
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
}
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
if (event) {
this.setEventMetadata(event);
await this.fetchEditsWhereNeeded(event);
}
this.timeline = this.events;
}
/**
@@ -717,6 +721,16 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
}
/**
* The live event timeline for this thread.
* @deprecated Present for backwards compatibility.
* Use this.events instead
* @returns The live event timeline for this thread.
*/
public get timeline(): MatrixEvent[] {
return this.events;
}
public get events(): MatrixEvent[] {
return this.liveTimeline.getEvents();
}
+7 -4
View File
@@ -16,7 +16,6 @@ limitations under the License.
import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
import { logger } from "../logger";
import { randomString } from "../randomstring";
@@ -129,6 +128,7 @@ export const generateAuthorizationUrl = async (
* @param nonce - state
* @param prompt - indicates to the OP which flow the user should see - eg login or registration
* See https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-prompt-parameter
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
* @returns a Promise with the url as a string
*/
export const generateOidcAuthorizationUrl = async ({
@@ -139,6 +139,7 @@ export const generateOidcAuthorizationUrl = async ({
identityServerUrl,
nonce,
prompt,
urlState,
}: {
clientId: string;
metadata: ValidatedIssuerMetadata;
@@ -147,8 +148,9 @@ export const generateOidcAuthorizationUrl = async ({
redirectUri: string;
nonce: string;
prompt?: string;
urlState?: string;
}): Promise<string> => {
const scope = await generateScope();
const scope = generateScope();
const oidcClient = new OidcClient({
...metadata,
client_id: clientId,
@@ -164,6 +166,7 @@ export const generateOidcAuthorizationUrl = async ({
state: userState,
nonce,
prompt,
url_state: urlState,
});
return request.url;
@@ -205,7 +208,7 @@ export const completeAuthorizationCodeGrant = async (
code: string,
state: string,
): Promise<{
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
oidcClientSettings: { clientId: string; issuer: string };
tokenResponse: BearerTokenResponse;
homeserverUrl: string;
idTokenClaims: IdTokenClaims;
@@ -235,7 +238,7 @@ export const completeAuthorizationCodeGrant = async (
// hydrate the sign in state and create a client
// the stored sign in state includes oidc configuration we set at the start of the oidc login flow
const signInState = SigninState.fromStorageString(stateString);
const signInState = await SigninState.fromStorageString(stateString);
const client = new OidcClient({ ...signInState, stateStore });
// validate the code and state, and attempt to swap the code for tokens
+17 -18
View File
@@ -14,36 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MetadataService, OidcClientSettingsStore, SigningKey } from "oidc-client-ts";
import { MetadataService, OidcClientSettingsStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { isValidatedIssuerMetadata, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate";
import { isValidatedIssuerMetadata, validateOIDCIssuerWellKnown } from "./validate";
import { Method, timeoutSignal } from "../http-api";
import { OidcClientConfig } from "./index";
/**
* @experimental
* Discover and 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
* Fetches https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein.
* When successful, validated metadata is returned
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @param issuer - the OIDC issuer as returned by the /auth_issuer API
* @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable
*/
export const discoverAndValidateAuthenticationConfig = async (
authenticationConfig?: IDelegatedAuthConfig,
): Promise<
IDelegatedAuthConfig & {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
> => {
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authenticationConfig);
export const discoverAndValidateOIDCIssuerWellKnown = async (issuer: string): Promise<OidcClientConfig> => {
const issuerOpenIdConfigUrl = new URL(".well-known/openid-configuration", issuer);
const issuerWellKnownResponse = await fetch(issuerOpenIdConfigUrl, {
method: Method.Get,
signal: timeoutSignal(5000),
});
const issuerWellKnown = await issuerWellKnownResponse.json();
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown);
// create a temporary settings store so we can use metadata service for discovery
// create a temporary settings store, so we can use metadata service for discovery
const settings = new OidcClientSettingsStore({
authority: homeserverAuthenticationConfig.issuer,
authority: issuer,
redirect_uri: "", // Not known yet, this is here to make the type checker happy
client_id: "", // Not known yet, this is here to make the type checker happy
});
@@ -54,7 +53,7 @@ export const discoverAndValidateAuthenticationConfig = async (
isValidatedIssuerMetadata(metadata);
return {
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
metadata,
signingKeys,
};
+12
View File
@@ -14,9 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { SigningKey } from "oidc-client-ts";
import { ValidatedIssuerConfig, ValidatedIssuerMetadata } from "./validate";
export * from "./authorize";
export * from "./discovery";
export * from "./error";
export * from "./register";
export * from "./tokenRefresher";
export * from "./validate";
/**
* Validated config for native OIDC authentication, as returned by {@link discoverAndValidateOIDCIssuerWellKnown}.
* Contains metadata and signing keys from the issuer's well-known (https://oidc-issuer.example.com/.well-known/openid-configuration).
*/
export interface OidcClientConfig extends ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
+36 -21
View File
@@ -14,21 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDelegatedAuthConfig } from "../client";
import { OidcClientConfig } from ".";
import { OidcError } from "./error";
import { Method } from "../http-api";
import { logger } from "../logger";
import { ValidatedIssuerConfig } from "./validate";
import { NonEmptyArray } from "../@types/common";
/**
* Client metadata passed to registration endpoint
*/
export type OidcRegistrationClientMetadata = {
clientName: string;
clientUri: string;
redirectUris: string[];
clientName: OidcRegistrationRequestBody["client_name"];
clientUri: OidcRegistrationRequestBody["client_uri"];
logoUri?: OidcRegistrationRequestBody["logo_uri"];
applicationType: OidcRegistrationRequestBody["application_type"];
redirectUris: OidcRegistrationRequestBody["redirect_uris"];
contacts: OidcRegistrationRequestBody["contacts"];
tosUri: OidcRegistrationRequestBody["tos_uri"];
policyUri: OidcRegistrationRequestBody["policy_uri"];
};
interface OidcRegistrationRequestBody {
client_name: string;
client_uri: string;
logo_uri?: string;
contacts: NonEmptyArray<string>;
tos_uri: string;
policy_uri: string;
redirect_uris?: NonEmptyArray<string>;
response_types?: NonEmptyArray<string>;
grant_types?: NonEmptyArray<string>;
id_token_signed_response_alg: string;
token_endpoint_auth_method: string;
application_type: "web" | "native";
}
/**
* Make the client registration request
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
@@ -42,7 +62,7 @@ const doRegistration = async (
clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => {
// https://openid.net/specs/openid-connect-registration-1_0.html
const metadata = {
const metadata: OidcRegistrationRequestBody = {
client_name: clientMetadata.clientName,
client_uri: clientMetadata.clientUri,
response_types: ["code"],
@@ -50,7 +70,11 @@ const doRegistration = async (
redirect_uris: clientMetadata.redirectUris,
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: "web",
application_type: clientMetadata.applicationType,
logo_uri: clientMetadata.logoUri,
contacts: clientMetadata.contacts,
policy_uri: clientMetadata.policyUri,
tos_uri: clientMetadata.tosUri,
};
const headers = {
"Accept": "application/json",
@@ -87,26 +111,17 @@ const doRegistration = async (
/**
* Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from ValidatedServerConfig
* @param clientName - Client name to register with the OP, eg 'Element'
* @param baseUrl - URL of the home page of the Client, eg 'https://app.element.io/'
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
* @param clientMetadata - The metadata for the client which to register
* @returns Promise<string> resolved with registered clientId
* @throws when registration is not supported, on failed request or invalid response
*/
export const registerOidcClient = async (
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig,
clientName: string,
baseUrl: string,
delegatedAuthConfig: OidcClientConfig,
clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => {
const clientMetadata = {
clientName,
clientUri: baseUrl,
redirectUris: [baseUrl],
};
if (!delegatedAuthConfig.registrationEndpoint) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
return clientId;
return doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
};
+6 -7
View File
@@ -17,9 +17,8 @@ limitations under the License.
import { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { AccessTokens } from "../http-api";
import { IDelegatedAuthConfig } from "../client";
import { generateScope } from "./authorize";
import { discoverAndValidateAuthenticationConfig } from "./discovery";
import { discoverAndValidateOIDCIssuerWellKnown } from "./discovery";
import { logger } from "../logger";
/**
@@ -42,9 +41,9 @@ export class OidcTokenRefresher {
public constructor(
/**
* Delegated auth config as found in matrix client .well-known
* The OIDC issuer as returned by the /auth_issuer API
*/
authConfig: IDelegatedAuthConfig,
issuer: string,
/**
* id of this client as registered with the OP
*/
@@ -63,17 +62,17 @@ export class OidcTokenRefresher {
*/
private readonly idTokenClaims: IdTokenClaims,
) {
this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri);
this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri);
}
private async initialiseOidcClient(
authConfig: IDelegatedAuthConfig,
issuer: string,
clientId: string,
deviceId: string,
redirectUri: string,
): Promise<void> {
try {
const config = await discoverAndValidateAuthenticationConfig(authConfig);
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
const scope = generateScope(deviceId);
+26 -32
View File
@@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import jwtDecode from "jwt-decode";
import { jwtDecode } from "jwt-decode";
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { logger } from "../logger";
import { OidcError } from "./error";
@@ -31,31 +30,8 @@ export type ValidatedIssuerConfig = {
authorizationEndpoint: string;
tokenEndpoint: string;
registrationEndpoint?: string;
};
/**
* Validates MSC2965 m.authentication config
* Returns valid configuration
* @param wellKnown - client well known as returned from ./well-known/client/matrix
* @returns config - when present and valid
* @throws when config is not found or invalid
*/
export const validateWellKnownAuthentication = (authentication?: IDelegatedAuthConfig): IDelegatedAuthConfig => {
if (!authentication) {
throw new Error(OidcError.NotSupported);
}
if (
typeof authentication.issuer === "string" &&
(!authentication.hasOwnProperty("account") || typeof authentication.account === "string")
) {
return {
issuer: authentication.issuer,
account: authentication.account,
};
}
throw new Error(OidcError.Misconfigured);
accountManagementEndpoint?: string;
accountManagementActionsSupported?: string[];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -74,6 +50,16 @@ const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string)
}
return true;
};
const optionalStringArrayProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
if (
!!wellKnown[key] &&
(!Array.isArray(wellKnown[key]) || !(<unknown[]>wellKnown[key]).every((v) => typeof v === "string"))
) {
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
};
const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, value: any): boolean => {
const array = wellKnown[key];
if (!array || !Array.isArray(array) || !array.includes(value)) {
@@ -102,6 +88,8 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
requiredStringProperty(wellKnown, "token_endpoint"),
requiredStringProperty(wellKnown, "revocation_endpoint"),
optionalStringProperty(wellKnown, "registration_endpoint"),
optionalStringProperty(wellKnown, "account_management_uri"),
optionalStringArrayProperty(wellKnown, "account_management_actions_supported"),
requiredArrayValue(wellKnown, "response_types_supported", "code"),
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
requiredArrayValue(wellKnown, "code_challenge_methods_supported", "S256"),
@@ -109,10 +97,12 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
if (!isInvalid) {
return {
authorizationEndpoint: wellKnown["authorization_endpoint"],
tokenEndpoint: wellKnown["token_endpoint"],
registrationEndpoint: wellKnown["registration_endpoint"],
} as ValidatedIssuerConfig;
authorizationEndpoint: <string>wellKnown["authorization_endpoint"],
tokenEndpoint: <string>wellKnown["token_endpoint"],
registrationEndpoint: <string>wellKnown["registration_endpoint"],
accountManagementEndpoint: <string>wellKnown["account_management_uri"],
accountManagementActionsSupported: <string[]>wellKnown["account_management_actions_supported"],
};
}
logger.error("Issuer configuration not valid");
@@ -134,7 +124,11 @@ export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
| "response_types_supported"
| "grant_types_supported"
| "code_challenge_methods_supported"
>;
> & {
// MSC2965 extensions to the OIDC spec
account_management_uri?: string;
account_management_actions_supported?: string[];
};
/**
* Wraps validateOIDCIssuerWellKnown in a type assertion
+28 -34
View File
@@ -17,12 +17,12 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import { ICrossSigningKey, IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { CrossSigningInfo } from "../crypto/CrossSigning";
import { DeviceInfo } from "../crypto/deviceinfo";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";
import { sleep } from "../utils";
import { CrossSigningKey } from "../crypto-api";
import { Device } from "../matrix";
enum PayloadType {
Start = "m.login.start",
@@ -116,7 +116,7 @@ export class MSC3906Rendezvous {
await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] });
logger.info("Waiting for other device to chose protocol");
logger.info("Waiting for other device to choose protocol");
const { type, protocol, outcome } = await this.receive();
if (type === PayloadType.Finish) {
@@ -178,12 +178,8 @@ export class MSC3906Rendezvous {
return deviceId;
}
private async verifyAndCrossSignDevice(
deviceInfo: DeviceInfo,
): Promise<CrossSigningInfo | DeviceInfo | ICrossSigningKey | undefined> {
if (!this.client.crypto) {
throw new Error("Crypto not available on client");
}
private async verifyAndCrossSignDevice(deviceInfo: Device): Promise<void> {
const crypto = this.client.getCrypto()!;
if (!this.newDeviceId) {
throw new Error("No new device ID set");
@@ -196,36 +192,32 @@ export class MSC3906Rendezvous {
);
}
const userId = this.client.getUserId();
const userId = this.client.getSafeUserId();
if (!userId) {
throw new Error("No user ID set");
}
// mark the device as verified locally + cross sign
logger.info(`Marking device ${this.newDeviceId} as verified`);
const info = await this.client.crypto.setDeviceVerification(userId, this.newDeviceId, true, false, true);
await crypto.setDeviceVerified(userId, this.newDeviceId, true);
await crypto.crossSignDevice(this.newDeviceId);
const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!;
const masterPublicKey = (await crypto.getCrossSigningKeyId(CrossSigningKey.Master)) ?? undefined;
const ourDeviceId = this.client.getDeviceId()!;
const ourDeviceKey = (await crypto.getOwnDeviceKeys()).ed25519;
await this.send({
type: PayloadType.Finish,
outcome: Outcome.Verified,
verifying_device_id: this.client.getDeviceId()!,
verifying_device_key: this.client.getDeviceEd25519Key()!,
verifying_device_id: ourDeviceId,
verifying_device_key: ourDeviceKey,
master_key: masterPublicKey,
});
return info;
}
/**
* Verify the device and cross-sign it.
* @param timeout - time in milliseconds to wait for device to come online
* @returns the new device info if the device was verified
*/
public async verifyNewDeviceOnExistingDevice(
timeout = 10 * 1000,
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
public async verifyNewDeviceOnExistingDevice(timeout = 10 * 1000): Promise<void> {
if (!this.newDeviceId) {
throw new Error("No new device to sign");
}
@@ -235,31 +227,33 @@ export class MSC3906Rendezvous {
return undefined;
}
if (!this.client.crypto) {
const crypto = this.client.getCrypto();
if (!crypto) {
throw new Error("Crypto not available on client");
}
const userId = this.client.getUserId();
if (!userId) {
throw new Error("No user ID set");
}
let deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId);
let deviceInfo = await this.getOwnDevice(this.newDeviceId);
if (!deviceInfo) {
logger.info("Going to wait for new device to be online");
await sleep(timeout);
deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId);
deviceInfo = await this.getOwnDevice(this.newDeviceId);
}
if (deviceInfo) {
return await this.verifyAndCrossSignDevice(deviceInfo);
await this.verifyAndCrossSignDevice(deviceInfo);
return;
}
throw new Error("Device not online within timeout");
}
private async getOwnDevice(deviceId: string): Promise<Device | undefined> {
const userId = this.client.getSafeUserId();
const ownDeviceInfo = await this.client.getCrypto()!.getUserDeviceInfo([userId]);
return ownDeviceInfo.get(userId)?.get(deviceId);
}
public async cancel(reason: RendezvousFailureReason): Promise<void> {
this.onFailure?.(reason);
await this.channel.cancel(reason);
+4 -1
View File
@@ -73,7 +73,10 @@ export class KeyClaimManager {
throw new Error(`Cannot ensure Olm sessions: shutting down`);
}
logger.info("Checking for missing Olm sessions");
const claimRequest = await this.olmMachine.getMissingSessions(userList);
// By passing the userId array to rust we transfer ownership of the items to rust, causing
// them to be invalidated on the JS side as soon as the method is called.
// As we haven't created the `userList` let's clone the users, to not break the caller from re-using it.
const claimRequest = await this.olmMachine.getMissingSessions(userList.map((u) => u.clone()));
if (claimRequest) {
logger.info("Making /keys/claim request");
await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest);
+37 -12
View File
@@ -15,11 +15,11 @@ limitations under the License.
*/
import {
OlmMachine,
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
OlmMachine,
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
@@ -27,8 +27,8 @@ import {
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { logDuration, QueryDict } from "../utils";
import { calculateRetryBackoff, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { logDuration, QueryDict, sleep } from "../utils";
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
import { UIAResponse } from "../@types/uia";
import { ToDeviceMessageId } from "../@types/event";
@@ -71,15 +71,15 @@ export class OutgoingRequestProcessor {
* for the complete list of request types
*/
if (msg instanceof KeysUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
resp = await this.requestWithRetry(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
} else if (msg instanceof KeysQueryRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
resp = await this.requestWithRetry(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
} else if (msg instanceof KeysClaimRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
resp = await this.requestWithRetry(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
} else if (msg instanceof SignatureUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
resp = await this.requestWithRetry(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
} else if (msg instanceof KeysBackupRequest) {
resp = await this.rawJsonRequest(
resp = await this.requestWithRetry(
Method.Put,
"/_matrix/client/v3/room_keys/keys",
{ version: msg.version },
@@ -91,7 +91,7 @@ export class OutgoingRequestProcessor {
const path =
`/_matrix/client/v3/rooms/${encodeURIComponent(msg.room_id)}/send/` +
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
resp = await this.requestWithRetry(Method.Put, path, {}, msg.body);
} else if (msg instanceof UploadSigningKeysRequest) {
await this.makeRequestWithUIA(
Method.Post,
@@ -154,7 +154,7 @@ export class OutgoingRequestProcessor {
const path =
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(request.event_type)}/` +
encodeURIComponent(request.txn_id);
return await this.rawJsonRequest(Method.Put, path, {}, request.body);
return await this.requestWithRetry(Method.Put, path, {}, request.body);
}
private async makeRequestWithUIA<T>(
@@ -165,7 +165,7 @@ export class OutgoingRequestProcessor {
uiaCallback: UIAuthCallback<T> | undefined,
): Promise<string> {
if (!uiaCallback) {
return await this.rawJsonRequest(method, path, queryParams, body);
return await this.requestWithRetry(method, path, queryParams, body);
}
const parsedBody = JSON.parse(body);
@@ -176,7 +176,7 @@ export class OutgoingRequestProcessor {
if (auth !== null) {
newBody.auth = auth;
}
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
const resp = await this.requestWithRetry(method, path, queryParams, JSON.stringify(newBody));
return JSON.parse(resp) as T;
};
@@ -184,6 +184,31 @@ export class OutgoingRequestProcessor {
return JSON.stringify(resp);
}
private async requestWithRetry(
method: Method,
path: string,
queryParams: QueryDict,
body: string,
): Promise<string> {
let currentRetryCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await this.rawJsonRequest(method, path, queryParams, body);
} catch (e) {
currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true);
if (backoff < 0) {
// Max number of retries reached, or error is not retryable. rethrow the error
throw e;
}
// wait for the specified time and then retry the request
await sleep(backoff);
}
}
}
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
const opts = {
// inhibit the JSON stringification and parsing within HttpApi.
+3 -1
View File
@@ -88,7 +88,8 @@ export class RoomEncryptor {
*/
public onCryptoEvent(config: IContent): void {
if (JSON.stringify(this.encryptionSettings) != JSON.stringify(config)) {
this.prefixedLogger.error(`Ignoring m.room.encryption event which requests a change of config`);
// This should currently be unreachable, since the Rust SDK will reject any attempts to change config.
throw new Error("Cannot reconfigure an active RoomEncryptor");
}
}
@@ -258,6 +259,7 @@ export class RoomEncryptor {
await logDuration(this.prefixedLogger, "shareRoomKey", async () => {
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
new RoomId(this.room.roomId),
// safe to pass without cloning, as it's not reused here (before or after)
userList,
rustEncryptionSettings,
);
+90 -25
View File
@@ -29,13 +29,14 @@ import { logger } from "../logger";
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
import { CryptoEvent, IMegolmSessionData } from "../crypto";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { encodeUri, immediate, logDuration } from "../utils";
import { encodeUri, logDuration } from "../utils";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { sleep } from "../utils";
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
import { IEncryptedPayload } from "../crypto/aes";
import { ImportRoomKeyProgressData, ImportRoomKeysOpts } from "../crypto-api";
import { IKeyBackupInfo } from "../crypto/keybackup";
import { IKeyBackup } from "../crypto/backup";
/** Authentification of the backup info, depends on algorithm */
type AuthData = KeyBackupInfo["auth_data"];
@@ -128,7 +129,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/**
* Handles a backup secret received event and store it if it matches the current backup version.
*
* @param secret - The secret as received from a `m.secert.send` event for secret `m.megolm_backup.v1`.
* @param secret - The secret as received from a `m.secret.send` event for secret `m.megolm_backup.v1`.
* @returns true if the secret is valid and has been stored, false otherwise.
*/
public async handleBackupSecretReceived(secret: string): Promise<boolean> {
@@ -141,7 +142,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
if (!backupCheck?.backupInfo?.version || !backupCheck.trustInfo.trusted) {
// There is no server-side key backup, or the backup is not signed by a trusted cross-signing key or trusted own device.
// This decryption key is useless to us.
logger.warn("Received backup decryption key, but there is no trusted server-side key backup");
logger.warn(
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
);
return false;
}
@@ -149,7 +152,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(backupCheck.backupInfo, backupDecryptionKey);
if (!privateKeyMatches) {
logger.debug(`onReceiveSecret: backup decryption key does not match current backup version`);
logger.warn(
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
);
// just ignore the secret
return false;
}
@@ -183,7 +188,18 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
* @returns a promise which resolves once the keys have been imported
*/
public async importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
const jsonKeys = JSON.stringify(keys);
await this.importRoomKeysAsJson(JSON.stringify(keys), opts);
}
/**
* Import 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
*/
public async importRoomKeysAsJson(jsonKeys: string, opts?: ImportRoomKeysOpts): Promise<void> {
await this.olmMachine.importExportedRoomKeys(jsonKeys, (progress: BigInt, total: BigInt): void => {
const importOpt: ImportRoomKeyProgressData = {
total: Number(total),
@@ -207,15 +223,18 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
}
keysByRoom.get(roomId)!.set(key.session_id, key);
}
await this.olmMachine.importBackedUpRoomKeys(keysByRoom, (progress: BigInt, total: BigInt): void => {
const importOpt: ImportRoomKeyProgressData = {
total: Number(total),
successes: Number(progress),
stage: "load_keys",
failures: 0,
};
opts?.progressCallback?.(importOpt);
});
await this.olmMachine.importBackedUpRoomKeys(
keysByRoom,
(progress: BigInt, total: BigInt, failures: BigInt): void => {
const importOpt: ImportRoomKeyProgressData = {
total: Number(total),
successes: Number(progress),
stage: "load_keys",
failures: Number(failures),
};
opts?.progressCallback?.(importOpt);
},
);
}
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
@@ -323,7 +342,13 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
await sleep(delay);
try {
let numFailures = 0; // number of consecutive network failures for exponential backoff
// number of consecutive network failures for exponential backoff
let numFailures = 0;
// The number of keys left to back up. (Populated lazily: see more comments below.)
let remainingToUploadCount: number | null = null;
// To avoid computing the key when only a few keys were added (after a sync for example),
// we compute the count only when at least two iterations are needed.
let isFirstIteration = true;
while (!this.stopped) {
// Get a batch of room keys to upload
@@ -342,6 +367,10 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
if (!request || this.stopped || !this.activeBackupVersion) {
logger.log(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
if (!request) {
// nothing more to upload
this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0);
}
return;
}
@@ -349,12 +378,34 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
numFailures = 0;
if (this.stopped) break;
try {
const keyCount = await this.olmMachine.roomKeyCounts();
const remaining = keyCount.total - keyCount.backedUp;
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
} catch (err) {
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
// Key count performance (`olmMachine.roomKeyCounts()`) can be pretty bad on some configurations.
// In particular, we detected on some M1 macs that when the object store reaches a threshold, the count
// performance stops growing in O(n) and suddenly becomes very slow (40s, 60s or more).
// For reference, the performance drop occurs around 300-400k keys on the platforms where this issue is observed.
// Even on other configurations, the count can take several seconds.
// This will block other operations on the database, like sending messages.
//
// This is a workaround to avoid calling `olmMachine.roomKeyCounts()` too often, and only when necessary.
// We don't call it on the first loop because there could be only a few keys to upload, and we don't want to wait for the count.
if (!isFirstIteration && remainingToUploadCount === null) {
try {
const keyCount = await this.olmMachine.roomKeyCounts();
remainingToUploadCount = keyCount.total - keyCount.backedUp;
} catch (err) {
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
}
}
if (remainingToUploadCount !== null) {
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remainingToUploadCount);
const keysCountInBatch = this.keysCountInBatch(request);
// `OlmMachine.roomKeyCounts` is called only once for the current backupKeysLoop. But new
// keys could be added during the current loop (after a sync for example).
// So the count can get out of sync with the real number of remaining keys to upload.
// Depending on the number of new keys imported and the time to complete the loop,
// this could result in multiple events being emitted with a remaining key count of 0.
remainingToUploadCount = Math.max(remainingToUploadCount - keysCountInBatch, 0);
}
} catch (err) {
numFailures++;
@@ -378,7 +429,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
// wait for that and then continue?
const waitTime = err.data.retry_after_ms;
if (waitTime > 0) {
sleep(waitTime);
await sleep(waitTime);
continue;
} // else go to the normal backoff
}
@@ -388,12 +439,29 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
isFirstIteration = false;
}
} finally {
this.backupKeysLoopRunning = false;
}
}
/**
* Utility method to count the number of keys in a backup request, in order to update the remaining keys count.
* This should be the chunk size of the backup request for all requests but the last, but we don't have access to it
* (it's static in the Rust SDK).
* @param batch - The backup request to count the keys from.
*
* @returns The number of keys in the backup request.
*/
private keysCountInBatch(batch: RustSdkCryptoJs.KeysBackupRequest): number {
const parsedBody: IKeyBackup = JSON.parse(batch.body);
let count = 0;
for (const { sessions } of Object.values(parsedBody.rooms)) {
count += Object.keys(sessions).length;
}
return count;
}
/**
* Get information about the current key backup from the server
*
@@ -534,9 +602,6 @@ export class RustBackupDecryptor implements BackupDecryptor {
);
decrypted.session_id = sessionId;
keys.push(decrypted);
// there might be lots of sessions, so don't hog the event loop
await immediate();
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
+69 -7
View File
@@ -15,12 +15,15 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { StoreHandle } from "@matrix-org/matrix-sdk-crypto-wasm";
import { RustCrypto } from "./rust-crypto";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { ServerSideSecretStorage } from "../secret-storage";
import { ICryptoCallbacks } from "../crypto";
import { Logger } from "../logger";
import { CryptoStore, MigrationState } from "../crypto/store/base";
import { migrateFromLegacyCrypto, migrateRoomSettingsFromLegacyCrypto } from "./libolm_migration";
/**
* Create a new `RustCrypto` implementation
@@ -63,6 +66,19 @@ export async function initRustCrypto(args: {
* will be unencrypted.
*/
storePassphrase?: string;
/** If defined, we will check if any data needs migrating from this store to the rust store. */
legacyCryptoStore?: CryptoStore;
/** The pickle key for `legacyCryptoStore` */
legacyPickleKey?: string;
/**
* A callback which will receive progress updates on migration from `legacyCryptoStore`.
*
* Called with (-1, -1) to mark the end of migration.
*/
legacyMigrationProgressListener?: (progress: number, total: number) => void;
}): Promise<RustCrypto> {
const { logger } = args;
@@ -73,6 +89,21 @@ export async function initRustCrypto(args: {
// enable tracing in the rust-sdk
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
logger.debug("Opening Rust CryptoStore");
const storeHandle: StoreHandle = await StoreHandle.open(
args.storePrefix ?? undefined,
(args.storePrefix && args.storePassphrase) ?? undefined,
);
if (args.legacyCryptoStore) {
// We have a legacy crypto store, which we may need to migrate from.
await migrateFromLegacyCrypto({
legacyStore: args.legacyCryptoStore,
storeHandle,
...args,
});
}
const rustCrypto = await initOlmMachine(
logger,
args.http,
@@ -80,10 +111,12 @@ export async function initRustCrypto(args: {
args.deviceId,
args.secretStorage,
args.cryptoCallbacks,
args.storePrefix,
args.storePassphrase,
storeHandle,
args.legacyCryptoStore,
);
storeHandle.free();
logger.debug("Completed rust crypto-sdk setup");
return rustCrypto;
}
@@ -95,27 +128,38 @@ async function initOlmMachine(
deviceId: string,
secretStorage: ServerSideSecretStorage,
cryptoCallbacks: ICryptoCallbacks,
storePrefix: string | null,
storePassphrase: string | undefined,
storeHandle: StoreHandle,
legacyCryptoStore?: CryptoStore,
): Promise<RustCrypto> {
logger.debug("Init OlmMachine");
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(
const olmMachine = await RustSdkCryptoJs.OlmMachine.initFromStore(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.DeviceId(deviceId),
storePrefix ?? undefined,
(storePrefix && storePassphrase) ?? undefined,
storeHandle,
);
// A final migration step, now that we have an OlmMachine.
if (legacyCryptoStore) {
await migrateRoomSettingsFromLegacyCrypto({
logger,
legacyStore: legacyCryptoStore,
olmMachine,
});
}
// Disable room key requests, per https://github.com/vector-im/element-web/issues/26524.
olmMachine.roomKeyRequestsEnabled = false;
const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
rustCrypto.onRoomKeysUpdated(sessions),
);
await olmMachine.registerUserIdentityUpdatedCallback((userId: RustSdkCryptoJs.UserId) =>
rustCrypto.onUserIdentityUpdated(userId),
);
await olmMachine.registerDevicesUpdatedCallback((userIds: string[]) => rustCrypto.onDevicesUpdated(userIds));
// Check if there are any key backup secrets pending processing. There may be multiple secrets to process if several devices have gossiped them.
// The `registerReceiveSecretCallback` function will only be triggered for new secrets. If the client is restarted before processing them, the secrets will need to be manually handled.
@@ -139,5 +183,23 @@ async function initOlmMachine(
// XXX: find a less hacky way to do this.
await olmMachine.outgoingRequests();
if (legacyCryptoStore && (await legacyCryptoStore.containsData())) {
const migrationState = await legacyCryptoStore.getMigrationState();
if (migrationState < MigrationState.INITIAL_OWN_KEY_QUERY_DONE) {
logger.debug(`Performing initial key query after migration`);
// We need to do an initial keys query so that the rust stack can properly update trust of
// the user device and identity from the migrated private keys.
// If not done, there is a short period where the own device/identity trust will be undefined after migration.
try {
await rustCrypto.userHasCrossSigningKeys(userId);
} catch (e) {
// We don't want to fail the startup if this fails, but we do want to log it.
// It will be retried by the sdk.
logger.error("Failed to check for cross-signing keys after migration", e);
}
await legacyCryptoStore.setMigrationState(MigrationState.INITIAL_OWN_KEY_QUERY_DONE);
}
}
return rustCrypto;
}
+382
View File
@@ -0,0 +1,382 @@
/*
Copyright 2023-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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { Logger } from "../logger";
import { CryptoStore, MigrationState, SecretStorePrivateKeys } from "../crypto/store/base";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, IEncryptedPayload } from "../crypto/aes";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { requestKeyBackupVersion } from "./backup";
import { IRoomEncryption } from "../crypto/RoomList";
/**
* Determine if any data needs migrating from the legacy store, and do so.
*
* This migrates the base account data, and olm and megolm sessions. It does *not* migrate the room list, which should
* happen after an `OlmMachine` is created, via {@link migrateRoomSettingsFromLegacyCrypto}.
*
* @param args - Arguments object.
*/
export async function migrateFromLegacyCrypto(args: {
/** A `Logger` instance that will be used for debug output. */
logger: Logger;
/**
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
* We expect it to set the access token, etc.
*/
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>;
/** Store to migrate data from. */
legacyStore: CryptoStore;
/** Pickle key for `legacyStore`. */
legacyPickleKey?: string;
/** Local user's User ID. */
userId: string;
/** Local user's Device ID. */
deviceId: string;
/** Rust crypto store to migrate data into. */
storeHandle: RustSdkCryptoJs.StoreHandle;
/**
* A callback which will receive progress updates on migration from `legacyStore`.
*
* Called with (-1, -1) to mark the end of migration.
*/
legacyMigrationProgressListener?: (progress: number, total: number) => void;
}): Promise<void> {
const { logger, legacyStore } = args;
// initialise the rust matrix-sdk-crypto-wasm, if it hasn't already been done
await RustSdkCryptoJs.initAsync();
// enable tracing in the rust-sdk
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
if (!(await legacyStore.containsData())) {
// This store was never used. Nothing to migrate.
return;
}
await legacyStore.startup();
let migrationState = await legacyStore.getMigrationState();
if (migrationState >= MigrationState.MEGOLM_SESSIONS_MIGRATED) {
// All migration is done for now. The room list comes later, once we have an OlmMachine.
return;
}
const nOlmSessions = await countOlmSessions(logger, legacyStore);
const nMegolmSessions = await countMegolmSessions(logger, legacyStore);
const totalSteps = 1 + nOlmSessions + nMegolmSessions;
logger.info(
`Migrating data from legacy crypto store. ${nOlmSessions} olm sessions and ${nMegolmSessions} megolm sessions to migrate.`,
);
let stepsDone = 0;
function onProgress(steps: number): void {
stepsDone += steps;
args.legacyMigrationProgressListener?.(stepsDone, totalSteps);
}
onProgress(0);
const pickleKey = new TextEncoder().encode(args.legacyPickleKey);
if (migrationState === MigrationState.NOT_STARTED) {
logger.info("Migrating data from legacy crypto store. Step 1: base data");
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle);
migrationState = MigrationState.INITIAL_DATA_MIGRATED;
await legacyStore.setMigrationState(migrationState);
}
onProgress(1);
if (migrationState === MigrationState.INITIAL_DATA_MIGRATED) {
logger.info(
`Migrating data from legacy crypto store. Step 2: olm sessions (${nOlmSessions} sessions to migrate).`,
);
await migrateOlmSessions(logger, legacyStore, pickleKey, args.storeHandle, onProgress);
migrationState = MigrationState.OLM_SESSIONS_MIGRATED;
await legacyStore.setMigrationState(migrationState);
}
if (migrationState === MigrationState.OLM_SESSIONS_MIGRATED) {
logger.info(
`Migrating data from legacy crypto store. Step 3: megolm sessions (${nMegolmSessions} sessions to migrate).`,
);
await migrateMegolmSessions(logger, legacyStore, pickleKey, args.storeHandle, onProgress);
migrationState = MigrationState.MEGOLM_SESSIONS_MIGRATED;
await legacyStore.setMigrationState(migrationState);
}
// Migration is done.
args.legacyMigrationProgressListener?.(-1, -1);
logger.info("Migration from legacy crypto store complete");
}
async function migrateBaseData(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
legacyStore: CryptoStore,
pickleKey: Uint8Array,
storeHandle: RustSdkCryptoJs.StoreHandle,
): Promise<void> {
const migrationData = new RustSdkCryptoJs.BaseMigrationData();
migrationData.userId = new RustSdkCryptoJs.UserId(userId);
migrationData.deviceId = new RustSdkCryptoJs.DeviceId(deviceId);
await legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) =>
legacyStore.getAccount(txn, (a) => {
migrationData.pickledAccount = a ?? "";
}),
);
const recoveryKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "m.megolm_backup.v1");
// If we have a backup recovery key, we need to try to figure out which backup version it is for.
// All we can really do is ask the server for the most recent version.
if (recoveryKey) {
const backupInfo = await requestKeyBackupVersion(http);
if (backupInfo) {
migrationData.backupVersion = backupInfo.version;
migrationData.backupRecoveryKey = recoveryKey;
}
}
migrationData.privateCrossSigningMasterKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "master");
migrationData.privateCrossSigningSelfSigningKey = await getAndDecryptCachedSecretKey(
legacyStore,
pickleKey,
"self_signing",
);
migrationData.privateCrossSigningUserSigningKey = await getAndDecryptCachedSecretKey(
legacyStore,
pickleKey,
"user_signing",
);
await RustSdkCryptoJs.Migration.migrateBaseData(migrationData, pickleKey, storeHandle);
}
async function countOlmSessions(logger: Logger, legacyStore: CryptoStore): Promise<number> {
logger.debug("Counting olm sessions to be migrated");
let nSessions: number;
await legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
legacyStore.countEndToEndSessions(txn, (n) => (nSessions = n)),
);
return nSessions!;
}
async function countMegolmSessions(logger: Logger, legacyStore: CryptoStore): Promise<number> {
logger.debug("Counting megolm sessions to be migrated");
return await legacyStore.countEndToEndInboundGroupSessions();
}
async function migrateOlmSessions(
logger: Logger,
legacyStore: CryptoStore,
pickleKey: Uint8Array,
storeHandle: RustSdkCryptoJs.StoreHandle,
onBatchDone: (batchSize: number) => void,
): Promise<void> {
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await legacyStore.getEndToEndSessionsBatch();
if (batch === null) return;
logger.debug(`Migrating batch of ${batch.length} olm sessions`);
const migrationData: RustSdkCryptoJs.PickledSession[] = [];
for (const session of batch) {
const pickledSession = new RustSdkCryptoJs.PickledSession();
pickledSession.senderKey = session.deviceKey!;
pickledSession.pickle = session.session!;
pickledSession.lastUseTime = pickledSession.creationTime = new Date(session.lastReceivedMessageTs!);
migrationData.push(pickledSession);
}
await RustSdkCryptoJs.Migration.migrateOlmSessions(migrationData, pickleKey, storeHandle);
await legacyStore.deleteEndToEndSessionsBatch(batch);
onBatchDone(batch.length);
}
}
async function migrateMegolmSessions(
logger: Logger,
legacyStore: CryptoStore,
pickleKey: Uint8Array,
storeHandle: RustSdkCryptoJs.StoreHandle,
onBatchDone: (batchSize: number) => void,
): Promise<void> {
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await legacyStore.getEndToEndInboundGroupSessionsBatch();
if (batch === null) return;
logger.debug(`Migrating batch of ${batch.length} megolm sessions`);
const migrationData: RustSdkCryptoJs.PickledInboundGroupSession[] = [];
for (const session of batch) {
const sessionData = session.sessionData!;
const pickledSession = new RustSdkCryptoJs.PickledInboundGroupSession();
pickledSession.pickle = sessionData.session;
pickledSession.roomId = new RustSdkCryptoJs.RoomId(sessionData.room_id);
pickledSession.senderKey = session.senderKey;
pickledSession.senderSigningKey = sessionData.keysClaimed?.["ed25519"];
pickledSession.backedUp = !session.needsBackup;
// The Rust SDK `imported` flag is used to indicate the authenticity status of a Megolm
// session, which tells us whether we can reliably tell which Olm device is the owner
// (creator) of the session.
//
// If `imported` is true, then we have no cryptographic proof that the session is owned
// by the device with the identity key `senderKey`.
//
// Only Megolm sessions received directly from the owning device via an encrypted
// `m.room_key` to-device message should have `imported` flag set to false. Megolm
// sessions received by any other currently available means (i.e. from a
// `m.forwarded_room_key`, from v1 asymmetric server-side key backup, imported from a
// file, etc) should have the `imported` flag set to true.
//
// Messages encrypted with such Megolm sessions will have a grey shield in the UI
// ("Authenticity of this message cannot be guaranteed").
//
// However, we don't want to bluntly mark all sessions as `imported` during migration
// because users will suddenly start seeing all their historic messages decorated with a
// grey shield, which would be seen as a non-actionable regression.
//
// In the legacy crypto stack, the flag encoding similar information was called
// `InboundGroupSessionData.untrusted`. The value of this flag was set as follows:
//
// - For outbound Megolm sessions created by our own device, `untrusted` is `undefined`.
// - For Megolm sessions received via a `m.room_key` to-device message, `untrusted` is
// `undefined`.
// - For Megolm sessions received via a `m.forwarded_room_key` to-device message,
// `untrusted` is `true`.
// - For Megolm sessions imported from a (v1 asymmetric / "legacy") server-side key
// backup, `untrusted` is `true`.
// - For Megolm sessions imported from a file, untrusted is `undefined`.
//
// The main difference between the legacy crypto stack and the Rust crypto stack is that
// the Rust stack considers sessions imported from a file as `imported` (not
// authenticated). This is because the Megolm session export file format does not
// encode this authenticity information.
//
// Given this migration is only a one-time thing, we make a concession to accept the
// loss of information in this case, to avoid degrading UX in a non-actionable way.
pickledSession.imported = sessionData.untrusted === true;
migrationData.push(pickledSession);
}
await RustSdkCryptoJs.Migration.migrateMegolmSessions(migrationData, pickleKey, storeHandle);
await legacyStore.deleteEndToEndInboundGroupSessionsBatch(batch);
onBatchDone(batch.length);
}
}
/**
* Determine if any room settings need migrating from the legacy store, and do so.
*
* @param args - Arguments object.
*/
export async function migrateRoomSettingsFromLegacyCrypto({
logger,
legacyStore,
olmMachine,
}: {
/** A `Logger` instance that will be used for debug output. */
logger: Logger;
/** Store to migrate data from. */
legacyStore: CryptoStore;
/** OlmMachine to store the new data on. */
olmMachine: RustSdkCryptoJs.OlmMachine;
}): Promise<void> {
if (!(await legacyStore.containsData())) {
// This store was never used. Nothing to migrate.
return;
}
const migrationState = await legacyStore.getMigrationState();
if (migrationState >= MigrationState.ROOM_SETTINGS_MIGRATED) {
// We've already migrated the room settings.
return;
}
let rooms: Record<string, IRoomEncryption> = {};
await legacyStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
legacyStore.getEndToEndRooms(txn, (result) => {
rooms = result;
});
});
logger.debug(`Migrating ${Object.keys(rooms).length} sets of room settings`);
for (const [roomId, legacySettings] of Object.entries(rooms)) {
try {
const rustSettings = new RustSdkCryptoJs.RoomSettings();
if (legacySettings.algorithm !== "m.megolm.v1.aes-sha2") {
logger.warn(`Room ${roomId}: ignoring room with invalid algorithm ${legacySettings.algorithm}`);
continue;
}
rustSettings.algorithm = RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2;
rustSettings.sessionRotationPeriodMs = legacySettings.rotation_period_ms;
rustSettings.sessionRotationPeriodMessages = legacySettings.rotation_period_msgs;
await olmMachine.setRoomSettings(new RustSdkCryptoJs.RoomId(roomId), rustSettings);
// We don't attempt to clear out the settings from the old store, or record where we've gotten up to,
// which means that if the app gets restarted while we're in the middle of this migration, we'll start
// again from scratch. So be it. Given that legacy crypto loads the whole room list into memory on startup
// anyway, we know it can't be that big.
} catch (e) {
logger.warn(`Room ${roomId}: ignoring settings ${JSON.stringify(legacySettings)} which caused error ${e}`);
}
}
logger.debug(`Completed room settings migration`);
await legacyStore.setMigrationState(MigrationState.ROOM_SETTINGS_MIGRATED);
}
async function getAndDecryptCachedSecretKey(
legacyStore: CryptoStore,
legacyPickleKey: Uint8Array,
name: string,
): Promise<string | undefined> {
let encodedKey: IEncryptedPayload | null = null;
await legacyStore.doTxn("readonly", "account", (txn) => {
legacyStore.getSecretStorePrivateKey(
txn,
(k) => {
encodedKey = k as IEncryptedPayload | null;
},
name as keyof SecretStorePrivateKeys,
);
});
return encodedKey === null ? undefined : await decryptAES(encodedKey, legacyPickleKey, name);
}
+89 -2
View File
@@ -311,6 +311,16 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
return `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`;
}
/**
* Implementation of {@link CryptoApi#isEncryptionEnabledInRoom}.
*/
public async isEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
const roomSettings: RustSdkCryptoJs.RoomSettings | undefined = await this.olmMachine.getRoomSettings(
new RustSdkCryptoJs.RoomId(roomId),
);
return Boolean(roomSettings?.algorithm);
}
/**
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
*/
@@ -336,10 +346,18 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
return JSON.parse(raw);
}
public async exportRoomKeysAsJson(): Promise<string> {
return await this.olmMachine.exportRoomKeys(() => true);
}
public async importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
return await this.backupManager.importRoomKeys(keys, opts);
}
public async importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise<void> {
return await this.backupManager.importRoomKeysAsJson(keys, opts);
}
/**
* Implementation of {@link CryptoApi.userHasCrossSigningKeys}.
*/
@@ -359,7 +377,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
/* make sure we have an *up-to-date* idea of the user's cross-signing keys. This is important, because if we
* return "false" here, we will end up generating new cross-signing keys and replacing the existing ones.
*/
const request = this.olmMachine.queryKeysForUsers([rustTrackedUser]);
const request = this.olmMachine.queryKeysForUsers(
// clone as rust layer will take ownership and it's reused later
[rustTrackedUser.clone()],
);
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
}
const userIdentity = await this.olmMachine.getIdentity(rustTrackedUser);
@@ -1282,7 +1303,27 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
*/
public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> {
const config = event.getContent();
const settings = new RustSdkCryptoJs.RoomSettings();
if (config.algorithm === "m.megolm.v1.aes-sha2") {
settings.algorithm = RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2;
} else {
// Among other situations, this happens if the crypto state event is redacted.
this.logger.warn(`Room ${room.roomId}: ignoring crypto event with invalid algorithm ${config.algorithm}`);
return;
}
try {
settings.sessionRotationPeriodMs = config.rotation_period_ms;
settings.sessionRotationPeriodMessages = config.rotation_period_msgs;
await this.olmMachine.setRoomSettings(new RustSdkCryptoJs.RoomId(room.roomId), settings);
} catch (e) {
this.logger.warn(`Room ${room.roomId}: ignoring crypto event which caused error: ${e}`);
return;
}
// If we got this far, the SDK found the event acceptable.
// We need to either create or update the active RoomEncryptor.
const existingEncryptor = this.roomEncryptors[room.roomId];
if (existingEncryptor) {
existingEncryptor.onCryptoEvent(config);
@@ -1408,7 +1449,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* Callback for `OlmMachine.registerUserIdentityUpdatedCallback`
*
* Called by the rust-sdk whenever there is an update to any user's cross-signing status. We re-check their trust
* status and emit a `UserTrustStatusChanged` event.
* status and emit a `UserTrustStatusChanged` event, as well as a `KeysChanged` if it is our own identity that changed.
*
* @param userId - the user with the updated identity
*/
@@ -1419,10 +1460,26 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
// If our own user identity has changed, we may now trust the key backup where we did not before.
// So, re-check the key backup status and enable it if available.
if (userId.toString() === this.userId) {
this.emit(CryptoEvent.KeysChanged, {});
await this.checkKeyBackupAndEnable();
}
}
/**
* Callback for `OlmMachine.registerDevicesUpdatedCallback`
*
* Called when users' devices have updated. Emits `WillUpdateDevices` and `DevicesUpdated`. In the JavaScript
* crypto backend, these events are called at separate times, with `WillUpdateDevices` being emitted just before
* the devices are saved, and `DevicesUpdated` being emitted just after. But the OlmMachine only gives us
* one event, so we emit both events here.
*
* @param userIds - an array of user IDs of users whose devices have updated.
*/
public async onDevicesUpdated(userIds: string[]): Promise<void> {
this.emit(CryptoEvent.WillUpdateDevices, userIds, false);
this.emit(CryptoEvent.DevicesUpdated, userIds, false);
}
/**
* Handles secret received from the rust secret inbox.
*
@@ -1795,6 +1852,9 @@ function rustEncryptionInfoToJsEncryptionInfo(
type RustCryptoEvents =
| CryptoEvent.VerificationRequestReceived
| CryptoEvent.UserTrustStatusChanged
| CryptoEvent.KeysChanged
| CryptoEvent.WillUpdateDevices
| CryptoEvent.DevicesUpdated
| RustBackupCryptoEvents;
type RustCryptoEventMap = {
@@ -1809,4 +1869,31 @@ type RustCryptoEventMap = {
[CryptoEvent.UserTrustStatusChanged]: (userId: string, userTrustLevel: UserVerificationStatus) => void;
[CryptoEvent.KeyBackupDecryptionKeyCached]: (version: string) => void;
/**
* Fires when the user's cross-signing keys have changed or cross-signing
* has been enabled/disabled. The client can use getStoredCrossSigningForUser
* with the user ID of the logged in user to check if cross-signing is
* enabled on the account. If enabled, it can test whether the current key
* is trusted using with checkUserTrust with the user ID of the logged
* in user. The checkOwnCrossSigningTrust function may be used to reconcile
* the trust in the account key.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* @experimental
*/
[CryptoEvent.KeysChanged]: (data: {}) => void;
/**
* Fires whenever the stored devices for a user will be updated
* @param users - A list of user IDs that will be updated
* @param initialFetch - If true, the store is empty (apart
* from our own device) and is being seeded.
*/
[CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void;
/**
* Fires whenever the stored devices for a user have changed
* @param users - A list of user IDs that were updated
* @param initialFetch - If true, the store was empty (apart
* from our own device) and has been seeded.
*/
[CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
} & RustBackupCryptoEventMap;
+87 -39
View File
@@ -15,9 +15,10 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { Emoji, QrState } from "@matrix-org/matrix-sdk-crypto-wasm";
import { QrState } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
GeneratedSas,
ShowQrCodeCallbacks,
ShowSasCallbacks,
VerificationPhase,
@@ -33,6 +34,7 @@ import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProc
import { TypedReEmitter } from "../ReEmitter";
import { MatrixEvent } from "../models/event";
import { EventType, MsgType } from "../@types/event";
import { defer, IDeferred } from "../utils";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
@@ -75,11 +77,16 @@ export class RustVerificationRequest
const onChange = async (): Promise<void> => {
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
// If we now have a `Verification` where we lacked one before, or we have transitioned from QR to SAS,
// wrap the new rust Verification as a js-sdk Verifier.
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
// - we now have a `Verification` where we lacked one before
// - we have transitioned from QR to SAS
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
// tried to start verification at the same time, and we lost the tie breaking
if (verification instanceof RustSdkCryptoJs.Sas) {
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
this.setVerifier(new RustSASVerifier(verification, this, outgoingRequestProcessor));
} else if (this._verifier instanceof RustSASVerifier) {
this._verifier.replaceInner(verification);
}
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
this.setVerifier(new RustQrCodeVerifier(verification, outgoingRequestProcessor));
@@ -425,7 +432,7 @@ export class RustVerificationRequest
* this verification.
*/
public get cancellationCode(): string | null {
throw new Error("not implemented");
return this.inner.cancelInfo?.cancelCode() ?? null;
}
/**
@@ -434,7 +441,14 @@ export class RustVerificationRequest
* Only defined when phase is Cancelled
*/
public get cancellingUserId(): string | undefined {
throw new Error("not implemented");
const cancelInfo = this.inner.cancelInfo;
if (!cancelInfo) {
return undefined;
} else if (cancelInfo.cancelledbyUs()) {
return this.olmMachine.userId.toString();
} else {
return this.inner.otherUserId.toString();
}
}
}
@@ -448,46 +462,45 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
VerifierEvent | VerificationRequestEvent,
VerifierEventHandlerMap & VerificationRequestEventHandlerMap
> {
/** A promise which completes when the verification completes (or rejects when it is cancelled/fails) */
protected readonly completionPromise: Promise<void>;
/** A deferred which completes when the verification completes (or rejects when it is cancelled/fails) */
protected readonly completionDeferred: IDeferred<void>;
public constructor(
protected readonly inner: InnerType,
protected inner: InnerType,
protected readonly outgoingRequestProcessor: OutgoingRequestProcessor,
) {
super();
this.completionPromise = new Promise<void>((resolve, reject) => {
const onChange = async (): Promise<void> => {
this.onChange();
if (this.inner.isDone()) {
resolve(undefined);
} else if (this.inner.isCancelled()) {
const cancelInfo = this.inner.cancelInfo()!;
reject(
new Error(
`Verification cancelled by ${
cancelInfo.cancelledbyUs() ? "us" : "them"
} with code ${cancelInfo.cancelCode()}: ${cancelInfo.reason()}`,
),
);
}
this.emit(VerificationRequestEvent.Change);
};
inner.registerChangesCallback(onChange);
this.completionDeferred = defer();
inner.registerChangesCallback(async () => {
this.onChange();
});
// stop the runtime complaining if nobody catches a failure
this.completionPromise.catch(() => null);
this.completionDeferred.promise.catch(() => null);
}
/**
* Hook which is called when the underlying rust class notifies us that there has been a change.
*
* Can be overridden by subclasses to see if we can notify the application about an update.
* Can be overridden by subclasses to see if we can notify the application about an update. The overriding method
* must call `super.onChange()`.
*/
protected onChange(): void {}
protected onChange(): void {
if (this.inner.isDone()) {
this.completionDeferred.resolve(undefined);
} else if (this.inner.isCancelled()) {
const cancelInfo = this.inner.cancelInfo()!;
this.completionDeferred.reject(
new Error(
`Verification cancelled by ${
cancelInfo.cancelledbyUs() ? "us" : "them"
} with code ${cancelInfo.cancelCode()}: ${cancelInfo.reason()}`,
),
);
}
this.emit(VerificationRequestEvent.Change);
}
/**
* Returns true if the verification has been cancelled, either by us or the other side.
@@ -557,6 +570,8 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
cancel: () => this.cancel(),
};
}
super.onChange();
}
/**
@@ -572,7 +587,7 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
this.emit(VerifierEvent.ShowReciprocateQr, this.callbacks);
}
// Nothing to do here but wait.
await this.completionPromise;
await this.completionDeferred.promise;
}
/**
@@ -649,28 +664,42 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
* or times out.
*/
public async verify(): Promise<void> {
await this.sendAccept();
await this.completionDeferred.promise;
}
/**
* Send the accept or start event, if it hasn't already been sent
*/
private async sendAccept(): Promise<void> {
const req: undefined | OutgoingRequest = this.inner.accept();
if (req) {
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
}
await this.completionPromise;
}
/** if we can now show the callbacks, do so */
protected onChange(): void {
super.onChange();
if (this.callbacks === null) {
const emoji: Array<Emoji> | undefined = this.inner.emoji();
const decimal = this.inner.decimals() as [number, number, number] | undefined;
const emoji = this.inner.emoji();
const decimal = this.inner.decimals();
if (emoji === undefined && decimal === undefined) {
return;
}
const sas: GeneratedSas = {};
if (emoji) {
sas.emoji = emoji.map((e) => [e.symbol, e.description]);
}
if (decimal) {
sas.decimal = [decimal[0], decimal[1], decimal[2]];
}
this.callbacks = {
sas: {
decimal: decimal,
emoji: emoji?.map((e) => [e.symbol, e.description]),
},
sas,
confirm: async (): Promise<void> => {
const requests: Array<OutgoingRequest> = await this.inner.confirm();
for (const m of requests) {
@@ -704,6 +733,25 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
public getShowSasCallbacks(): ShowSasCallbacks | null {
return this.callbacks;
}
/**
* Replace the inner Rust verifier with a different one.
*
* @param inner - the new Rust verifier
* @internal
*/
public replaceInner(inner: RustSdkCryptoJs.Sas): void {
if (this.inner != inner) {
this.inner = inner;
inner.registerChangesCallback(async () => {
this.onChange();
});
// replaceInner will only get called if we started the verification at the same time as the other side, and we lost
// the tie breaker. So we need to re-accept their verification.
this.sendAccept();
this.onChange();
}
}
}
/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */
+3 -28
View File
@@ -22,7 +22,7 @@ import { logger } from "./logger";
import { MatrixEvent } from "./models/event";
import { EventType } from "./@types/event";
import { defer, IDeferred, removeElement } from "./utils";
import { ConnectionError, MatrixError } from "./http-api";
import { calculateRetryBackoff, MatrixError } from "./http-api";
import { ISendEventResponse } from "./@types/requests";
const DEBUG = false; // set true to enable console logging.
@@ -43,38 +43,13 @@ type ProcessFunction<T> = (event: MatrixEvent) => Promise<T>;
// eslint-disable-next-line camelcase
export class MatrixScheduler<T = ISendEventResponse> {
/**
* Retries events up to 4 times using exponential backoff. This produces wait
* times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
* failure was due to a rate limited request, the time specified in the error is
* waited before being retried.
* Default retry algorithm for the matrix scheduler. Retries events up to 4 times with exponential backoff.
* @param attempts - Number of attempts that have been made, including the one that just failed (ie. starting at 1)
* @see retryAlgorithm
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
public static RETRY_BACKOFF_RATELIMIT(event: MatrixEvent | null, attempts: number, err: MatrixError): number {
if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) {
// client error; no amount of retrying with save you now.
return -1;
}
if (err instanceof ConnectionError) {
return -1;
}
// if event that we are trying to send is too large in any way then retrying won't help
if (err.name === "M_TOO_LARGE") {
return -1;
}
if (err.name === "M_LIMIT_EXCEEDED") {
const waitTime = err.data.retry_after_ms;
if (waitTime > 0) {
return waitTime;
}
}
if (attempts > 4) {
return -1; // give up
}
return 1000 * Math.pow(2, attempts);
return calculateRetryBackoff(err, attempts, false);
}
/**

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