Compare commits

..

164 Commits

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

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

* Tweak comments

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

---------

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

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

* Update doc

* Remove 504 from retryable

* Retry all 5xx and clarify client timeouts

* code review cleaning

* do not retry rust request if M_TOO_LARGE

* refactor use common retry alg between scheduler and rust requests

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

* Iterate

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

---------

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

* Prettier

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

---------

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

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

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

* Merge

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

* delint

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

* Sort package.json

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

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

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

---------

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

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

* Allow specifying url_state for dynamic oidc client registration

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

* Export NonEmptyArray type

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

* Allow specifying more OIDC client metadata for dynamic registration

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

* Export NonEmptyArray type

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

* Fix test

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

---------

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

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

* Remove stale release scripts

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

* Update pull request template to remove allchange behaviours

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

* Update label check automation

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

* mheap

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

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

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

* Use node-fetch@^2

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

---------

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

To support setting the 'marked unread' flag

* Await encrypted messages (#4063)

* await encrypted messages
+ fix comments

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

* fix Tests

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

* fix test

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

* make sonar happy

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

---------

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

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

* ignore memberships of users that are not in the call

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

* recompute memberships on room member change.

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

* fix Tests and add test for left member

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

* fix event type

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

* fix import desaster

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

* fix mocks

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

---------

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

---------

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

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

* recompute memberships on room member change.

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

* fix Tests and add test for left member

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

* fix event type

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

* fix import desaster

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

* fix mocks

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

---------

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

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

* fix Tests

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

* fix test

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

* make sonar happy

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

---------

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

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

* code review changes

* add unit tests for existsAndIsNotMigrated

* ensure indexeddb is clean for each state

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

* code review changes

* add unit tests for existsAndIsNotMigrated

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

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

* Fix merge-release-notes.js parsing

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

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

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

* Tweak release-drafter config

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

* Automate downstream dependency bumping

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

* Remove duplicated docs

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

* Delint

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

---------

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

* review: Better doc

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

---------

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

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

* Fix merge-release-notes.js parsing

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

* Discard changes to yarn.lock

* Update package.json

---------

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

* lint

* make changes from review

* apply changes from code review

* remove useless assertions

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

* fix bugs

* we don't actually need the .accept variable

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

* use defer to avoid using a closure

* lint

* prettier

* use the right name

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

* combine onChangeCallback with onChange

* apply changes from review

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

* lint

---------

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

* lint

* add missing stub function

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

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

* Use new crossSignDevice() function

* Mock crossSignDevice() function

* Fix type of parameter in mock

* review:  cleaning

* review: Remove unneeded defensive coding

* review: fix outdated documentation

* QR login: review, cleaning

* QR login | review: use getSafeUserId

---------

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

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

* add expire_ts

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

* rename expire_ts -> expires_ts

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

* allow events without `expires`

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

* fix test for expires_ts

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

* comment clarification

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

* add comment where one needs to use the origin_server_ts

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

* add additional expires_ts tests

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

* fix fake timer

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

* change priority order to favor expires

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

---------

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

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

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

* add test and catch more invalid keys

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

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

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

* Persist encryption state to the rust room list.

* `MatrixClient.shouldEncryptEventForRoom`: fix for rust crypto

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

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

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

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

* Convert `encryptEventIfNeeded` to async function

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

* Simplify `encryptAndSendEvent`

Rewrite this as async.

* Factor out `MatrixClient.shouldEncryptEventForRoom`

* Inline a call to `isRoomEncrypted`

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

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

fix #3986

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

* retroactively add breaking change note to changelog entry

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

---------

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

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

* fix type info

* use string cancel code and add test

* simplify code

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

---------

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

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

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

* Update the tests

* Appease the linter

* Add test to appease SonarCloud

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

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

---------

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

* Fix some tests

* more test fixes

* yet more fixes

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

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

* review: Improve doc

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

* review: Improve loop

* review: Add comment regarding slightly outdated remaining count

* Review: doc fix typo

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

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

* review: Missing await on sleep for limit exceeded

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

* Backup: add upload loop test for rust

* test: quick fix backup loop tests

* test: quick fix imports backup loop tests

* review: improve comment

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

* Review improve comment

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

* Review: Clean and improve tests

* fix: wrong test name

---------

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

* fix test compilation with initFromStore

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

* review: Better doc

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

* review: Better doc

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

* review: revert userIdentity free removal

---------

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

* backup chunk decryption jsdoc

* Review: fix capitalization

* review: better var name

* review: fix better iterate on object

* review: extract utility function

* review: Improve test, ensure mock calls

* review: Add more test for decryption or import failures

* Review: fix typo

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

---------

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

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

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

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

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

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

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

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

* Don't recreate the event when we update

and also add a comment to the test

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

This was apparently never tested, and was implemented incorrectly.

* Add `CryptoStore.countEndToEndInboundGroupSessions`

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

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

* Include "needsBackup" flag in inbound group session batches

* On startup, import data from libolm cryptostore

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

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

* Update README.md

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

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

* Fix lint

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

---------

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

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

* Factor out `requestKeyBackupVersion` utility

* Factor out `makeMatrixHttpApi` function

* Convert `initRustCrypto` to take a params object

* Improve logging in startup

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

* Fix typo

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

---------

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

* Ensure encryption order per room

* Remove unneeded fake timers

* review

* put back log duration

* fix wrong call

* code review

* Update src/rust-crypto/RoomEncryptor.ts

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

* Update src/rust-crypto/RoomEncryptor.ts

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

* Update src/rust-crypto/RoomEncryptor.ts

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

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

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

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

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

* Update src/rust-crypto/RoomEncryptor.ts

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

* Update src/rust-crypto/RoomEncryptor.ts

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

* fix link syntax

* remove xxx comment

* fix comment order

* Improve comment

* add log duration

* fix comment

* Update src/rust-crypto/RoomEncryptor.ts

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

* Update src/rust-crypto/RoomEncryptor.ts

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

---------

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

* fix broken conflict resolution

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

Caused versions to clobber each other's LCOV

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

* Move sonarcloud shard support into reusable workflow

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

---------

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

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

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

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

* Fix tests

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

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

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

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

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

* code review

* fix tests

* add recovery keys test for both backends

* fix api break on GeneratedSecretStorageKey

* fix test

* fix test

* Update src/crypto-api.ts

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

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

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

* Update src/crypto-api.ts

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

---------

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

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

* cleaning

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

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

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

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

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

* Fix import cycle

* Remove params from expected calls in tests to match

* Unused import
2023-12-14 10:39:43 +00:00
renovate[bot] b7d45e83f8 Update all non-major dependencies (#3955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 20:25:50 +00:00
renovate[bot] db0e3cfbb0 Update babel monorepo to v7.23.5 (#3953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 08:55:36 +00:00
renovate[bot] 87b90cc983 Update dawidd6/action-download-artifact digest to f29d1b6 (#3952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:29:52 +00:00
renovate[bot] cc9545e313 Update dependency @types/jest to v29.5.11 (#3954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:28:48 +00:00
renovate[bot] ed2792e6d8 Update dependency @types/node to v18.19.3 (#3956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:27:07 +00:00
151 changed files with 79027 additions and 2847 deletions
+11 -4
View File
@@ -1,8 +1,15 @@
* @matrix-org/element-web
/.github/workflows/** @matrix-org/element-web-app-team
/package.json @matrix-org/element-web-app-team
/yarn.lock @matrix-org/element-web-app-team
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/src/webrtc @matrix-org/element-call-reviewers
/src/matrixrtc @matrix-org/element-call-reviewers
/spec/*/webrtc @matrix-org/element-call-reviewers
/spec/*/matrixrtc @matrix-org/element-call-reviewers
/src/crypto @matrix-org/element-crypto-web-reviewers
/src/rust-crypto @matrix-org/element-crypto-web-reviewers
/spec/integ/crypto @matrix-org/element-crypto-web-reviewers
/spec/unit/crypto.spec.ts @matrix-org/element-crypto-web-reviewers
/spec/unit/crypto @matrix-org/element-crypto-web-reviewers
/spec/unit/rust-crypto @matrix-org/element-crypto-web-reviewers
-6
View File
@@ -5,9 +5,3 @@
- [ ] Tests written for new code (and old code if feasible)
- [ ] Linter and other CI checks pass
- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md))
<!--
If you would like to specify text for the changelog entry other than your PR title, add the following:
Notes: Add super cool feature
-->
+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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
uses: actions/download-artifact@v4
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: docs
path: docs
+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.85.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: |
+47 -6
View File
@@ -5,7 +5,13 @@ on:
secrets:
SONAR_TOKEN:
required: true
ELEMENT_BOT_TOKEN:
required: true
inputs:
sharded:
type: boolean
required: false
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
extra_args:
type: string
required: false
@@ -13,10 +19,12 @@ on:
jobs:
sonarqube:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'merge_group'
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
# 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 }}
@@ -25,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 }}
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()
+2 -28
View File
@@ -8,38 +8,12 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
steps:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
name: coverage
path: coverage
- id: extra_args
run: |
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
sharded: true
+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
+3 -3
View File
@@ -52,13 +52,13 @@ jobs:
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
coverage
!coverage/lcov-report
+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
+106
View File
@@ -1,3 +1,108 @@
Changes in [31.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.5.0) (2024-03-12)
==================================================================================================
## ✨ Features
* Update MSC2965 OIDC Discovery implementation ([#4064](https://github.com/matrix-org/matrix-js-sdk/pull/4064)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Add basic retry for rust crypto outgoing requests ([#4061](https://github.com/matrix-org/matrix-js-sdk/pull/4061)). Contributed by @BillCarsonFr.
Changes in [31.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.4.0) (2024-02-27)
==================================================================================================
## ✨ Features
* Validate `account_management_uri` and `account_management_actions_supported` from OIDC Issuer well-known ([#4074](https://github.com/matrix-org/matrix-js-sdk/pull/4074)). Contributed by @t3chguy.
* Allow specifying OIDC url state parameter for passing data to callback ([#4068](https://github.com/matrix-org/matrix-js-sdk/pull/4068)). Contributed by @t3chguy.
* Add getAuthIssuer method for MSC2965 ([#4071](https://github.com/matrix-org/matrix-js-sdk/pull/4071)). Contributed by @t3chguy.
* Allow specifying more OIDC client metadata for dynamic registration ([#4070](https://github.com/matrix-org/matrix-js-sdk/pull/4070)). Contributed by @t3chguy.
* Add unread marker event type ([#4069](https://github.com/matrix-org/matrix-js-sdk/pull/4069)). Contributed by @dbkr.
* Add "AsJson" forms of the key import/export methods ([#4057](https://github.com/matrix-org/matrix-js-sdk/pull/4057)). Contributed by @andybalaam.
## 🐛 Bug Fixes
* Ignore memberships of users that are not in the call ([#4065](https://github.com/matrix-org/matrix-js-sdk/pull/4065)). Contributed by @toger5.
* Await encrypted messages ([#4063](https://github.com/matrix-org/matrix-js-sdk/pull/4063)). Contributed by @toger5.
* ElementR | Ensure own user and device trust are updated after migration before giving back control to the app. ([#4059](https://github.com/matrix-org/matrix-js-sdk/pull/4059)). Contributed by @BillCarsonFr.
* Bump matrix-sdk-crypto-wasm to 4.5.0 ([#4060](https://github.com/matrix-org/matrix-js-sdk/pull/4060)). Contributed by @andybalaam.
Changes in [31.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.3.0) (2024-02-13)
==================================================================================================
## ✨ Features
* Add expire\_ts compatibility to matrixRTC ([#4032](https://github.com/matrix-org/matrix-js-sdk/pull/4032)). Contributed by @toger5.
* Element-R: support for migration of the room list from legacy crypto ([#4036](https://github.com/matrix-org/matrix-js-sdk/pull/4036)). Contributed by @richvdh.
* Element-R: check persistent room list for encryption config ([#4035](https://github.com/matrix-org/matrix-js-sdk/pull/4035)). Contributed by @richvdh.
* Support optional MSC3860 redirects ([#4007](https://github.com/matrix-org/matrix-js-sdk/pull/4007)). Contributed by @turt2live.
## 🐛 Bug Fixes
* WebR: migrate the megolm session imported flag ([#4037](https://github.com/matrix-org/matrix-js-sdk/pull/4037)). Contributed by @BillCarsonFr.
* ElementR: fix emoji verification stalling when both ends hit start at the same time ([#4004](https://github.com/matrix-org/matrix-js-sdk/pull/4004)). Contributed by @uhoreg.
* Dependencies: Bump wasm bindings version to 4.3.0 ([#4042](https://github.com/matrix-org/matrix-js-sdk/pull/4042)). Contributed by @BillCarsonFr.
* Element R: emit events when devices have changed ([#4019](https://github.com/matrix-org/matrix-js-sdk/pull/4019)). Contributed by @uhoreg.
* ElementR: report invalid keys rather than failing to restore from backup ([#4006](https://github.com/matrix-org/matrix-js-sdk/pull/4006)). Contributed by @uhoreg.
* Make `timeline` a getter ([#4022](https://github.com/matrix-org/matrix-js-sdk/pull/4022)). Contributed by @florianduros.
* Implement getting verification cancellation info in Rust crypto ([#3947](https://github.com/matrix-org/matrix-js-sdk/pull/3947)). Contributed by @uhoreg.
* Fix crypto migration for megolm sessions with no sender key ([#4024](https://github.com/matrix-org/matrix-js-sdk/pull/4024)). Contributed by @richvdh.
Changes in [31.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.2.0) (2024-01-31)
==================================================================================================
## ✨ Features
* Emit events during migration from libolm ([#3982](https://github.com/matrix-org/matrix-js-sdk/pull/3982)). Contributed by @richvdh.
* Support for migration from from libolm ([#3978](https://github.com/matrix-org/matrix-js-sdk/pull/3978)). Contributed by @richvdh.
## 🐛 Bug Fixes
* ElementR | backup: call expensive `roomKeyCounts` less often ([#4015](https://github.com/matrix-org/matrix-js-sdk/pull/4015)). Contributed by @BillCarsonFr.
* Decrypt and Import full backups in chunk with progress ([#4005](https://github.com/matrix-org/matrix-js-sdk/pull/4005)). Contributed by @BillCarsonFr.
* Fix new threads not appearing. ([#4009](https://github.com/matrix-org/matrix-js-sdk/pull/4009)). Contributed by @dbkr.
Changes in [31.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.1.0) (2024-01-19)
==================================================================================================
## ✨ Features
* Broaden spec version support ([#4016](https://github.com/matrix-org/matrix-js-sdk/pull/4016)). Contributed by @RiotRobot.
Changes in [31.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.0.0) (2024-01-16)
==================================================================================================
## 🚨 BREAKING CHANGES
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
## ✨ Features
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
* Send authenticated /versions request ([#3968](https://github.com/matrix-org/matrix-js-sdk/pull/3968)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Revert "Bump matrix-sdk-crypto-wasm to 3.6.0" ([#3991](https://github.com/matrix-org/matrix-js-sdk/pull/3991)). Contributed by @andybalaam.
* #22606 Fix "Remove" button to users without "m.room.redaction" ([#3981](https://github.com/matrix-org/matrix-js-sdk/pull/3981)). Contributed by @rashmitpankhania.
* ElementR: Ensure Encryption order per room ([#3973](https://github.com/matrix-org/matrix-js-sdk/pull/3973)). Contributed by @BillCarsonFr.
* Element-R: fix `bootstrapSecretStorage` not resetting key backup when requested ([#3976](https://github.com/matrix-org/matrix-js-sdk/pull/3976)). Contributed by @uhoreg.
Changes in [30.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.3.0) (2023-12-19)
==================================================================================================
## ✨ Features
* Element-R: disable sending room key requests ([#3939](https://github.com/matrix-org/matrix-js-sdk/pull/3939)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Fix notifications appearing for old events ([#3946](https://github.com/matrix-org/matrix-js-sdk/pull/3946)). Contributed by @dbkr.
* Don't back up keys that we got from backup ([#3934](https://github.com/matrix-org/matrix-js-sdk/pull/3934)). Contributed by @uhoreg.
* Fix upload with empty Content-Type ([#3918](https://github.com/matrix-org/matrix-js-sdk/pull/3918)). Contributed by @JakubOnderka.
* Prevent phantom notifications from events not in a room's timeline ([#3942](https://github.com/matrix-org/matrix-js-sdk/pull/3942)). Contributed by @dbkr.
Changes in [30.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.2.0) (2023-12-05)
==================================================================================================
## ✨ Features
@@ -36,6 +141,7 @@ Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30
## 🚨 BREAKING CHANGES
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
* `IndexedDBStore.startup()` must be called after using it on `sdk.createClient` now.
## 🦖 Deprecations
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
+7 -7
View File
@@ -58,7 +58,7 @@ await client.startClient({ initialSyncLimit: 10 });
You can perform a call to `/sync` to get the current state of the client:
```javascript
client.once("sync", function (state, prevState, res) {
client.once(ClientEvent.sync, function (state, prevState, res) {
if (state === "PREPARED") {
console.log("prepared");
} else {
@@ -83,7 +83,7 @@ client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
To listen for message events:
```javascript
client.on("Room.timeline", function (event, room, toStartOfTimeline) {
client.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
if (event.getType() !== "m.room.message") {
return; // only use messages
}
@@ -144,12 +144,12 @@ are updated.
```javascript
// Listen for low-level MatrixEvents
client.on("event", function (event) {
client.on(ClientEvent.Event, function (event) {
console.log(event.getType());
});
// Listen for typing changes
client.on("RoomMember.typing", function (event, member) {
client.on(RoomMemberEvent.Typing, function (event, member) {
if (member.typing) {
console.log(member.name + " is typing...");
} else {
@@ -211,7 +211,7 @@ const matrixClient = sdk.createClient({
### Automatically join rooms when invited
```javascript
matrixClient.on("RoomMember.membership", function (event, member) {
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
if (member.membership === "invite" && member.userId === myUserId) {
matrixClient.joinRoom(member.roomId).then(function () {
console.log("Auto-joined %s", member.roomId);
@@ -225,7 +225,7 @@ matrixClient.startClient();
### Print out messages for all rooms
```javascript
matrixClient.on("Room.timeline", function (event, room, toStartOfTimeline) {
matrixClient.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
if (toStartOfTimeline) {
return; // don't print paginated results
}
@@ -257,7 +257,7 @@ Output:
### Print out membership lists whenever they are changed
```javascript
matrixClient.on("RoomState.members", function (event, state, member) {
matrixClient.on(RoomStateEvent.Members, function (event, state, member) {
const room = matrixClient.getRoom(state.roomId);
if (!room) {
return;
-1
View File
@@ -4,6 +4,5 @@
# Deep dive
- [Release Process](release.md)
- [Storage notes](storage-notes.md)
- [Unverified devices](warning-on-unverified-devices.md)
-24
View File
@@ -1,24 +0,0 @@
# Release Process
## Hotfix and off-cycle releases
1. Prepare the `staging` branch by using the backport automation and manually merging
2. Go to [Releasing](#Releasing)
## Release candidates
1. Prepare the `staging` branch by running the [branch cut automation](https://github.com/vector-im/element-web/actions/workflows/release_prepare.yml)
2. Go to [Releasing](#Releasing)
## Releasing
1. Open the [Releases page](https://github.com/matrix-org/matrix-js-sdk/releases) and inspect the draft release there
2. Make any modifications to the release notes and tag/version as required
3. Run [workflow](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) with the type set appropriately
## Artifacts
Releasing the Matrix JS SDK has just two artifacts:
- Package published to [npm](https://github.com/matrix-org/matrix-js-sdk)
- Docs published to [Github Pages](https://matrix-org.github.io/matrix-js-sdk/)
+17 -16
View File
@@ -1,12 +1,12 @@
{
"name": "matrix-js-sdk",
"version": "30.3.0-rc.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",
@@ -16,7 +16,7 @@
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
"lint:js-fix": "prettier --loglevel=warn --write . && eslint --fix src spec",
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"test": "jest",
@@ -52,15 +52,15 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^3.4.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": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"allchange": "^1.0.6",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.54.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",
"fake-indexeddb": "^5.0.0",
"eslint-plugin-unicorn": "^51.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
"husky": "^8.0.3",
"husky": "^9.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.8",
"node-fetch": "^2.7.0",
"prettier": "3.2.5",
"rimraf": "^5.0.0",
"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);
+235 -29
View File
@@ -29,6 +29,7 @@ import {
getSyncResponse,
InitCrypto,
mkEventCustom,
mkMembershipCustom,
syncPromise,
} from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
@@ -38,6 +39,7 @@ import {
BOB_TEST_USER_ID,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
TEST_ROOM_ID,
TEST_ROOM_ID as ROOM_ID,
TEST_USER_ID,
} from "../../test-utils/test-data";
@@ -74,7 +76,7 @@ import {
mockSetupCrossSigningRequests,
mockSetupMegolmBackupRequests,
} from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { SecretStorageKeyDescription } from "../../../src/secret-storage";
import {
CrossSigningKey,
CryptoCallbacks,
@@ -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;
@@ -340,7 +339,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
function createCryptoCallbacks(): CryptoCallbacks {
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array };
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
const cacheSecretStorageKey = (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => {
cachedKey = {
keyId,
key,
@@ -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 */
@@ -398,17 +398,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
});
it("CryptoAPI.getOwnedDeviceKeys returns the correct values", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
await startClientAndAwaitFirstSync();
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
it("CryptoAPI.getOwnDeviceKeys returns plausible values", async () => {
const deviceKeys = await aliceClient.getCrypto()!.getOwnDeviceKeys();
expect(deviceKeys.curve25519).toEqual(keyReceiver.getDeviceKey());
expect(deviceKeys.ed25519).toEqual(keyReceiver.getSigningKey());
// We just check for a 43-character base64 string
expect(deviceKeys.curve25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
expect(deviceKeys.ed25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
});
it("Alice receives a megolm message", async () => {
@@ -707,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");
@@ -738,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");
@@ -766,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");
@@ -1069,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"]);
@@ -1384,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();
@@ -1400,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();
@@ -2075,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");
@@ -2098,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");
@@ -2195,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(() => {
@@ -2395,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();
@@ -2567,6 +2564,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await backupStatusUpdate;
}
describe("Generate 4S recovery keys", () => {
it("should create a random recovery key", async () => {
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase();
expect(generatedKey.privateKey).toBeDefined();
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
expect(generatedKey.privateKey.length).toBe(32);
expect(generatedKey.keyInfo?.passphrase).toBeUndefined();
expect(generatedKey.encodedPrivateKey).toBeDefined();
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
});
it("should create a recovery key from passphrase", async () => {
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase("mypassphrase");
expect(generatedKey.privateKey).toBeDefined();
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
expect(generatedKey.privateKey.length).toBe(32);
expect(generatedKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2");
expect(generatedKey.keyInfo?.passphrase?.iterations).toBe(500000);
expect(generatedKey.encodedPrivateKey).toBeDefined();
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
});
});
describe("bootstrapSecretStorage", () => {
// Doesn't work with legacy crypto, which will try to bootstrap even without private key, which is buggy.
newBackendOnly(
@@ -2592,6 +2613,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// check that the key content contains the key check info
const keyContent = accountDataAccumulator.accountDataEvents.get(
`m.secret_storage.key.${secretStorageKey}`,
)!;
// In order to verify if the key is valid, a zero secret is encrypted with the key
expect(keyContent.iv).toBeDefined();
expect(keyContent.mac).toBeDefined();
// Return the newly created key in the sync response
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
@@ -2719,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(() => {
@@ -2873,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);
@@ -2909,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();
@@ -33,7 +33,7 @@ import {
import { logger } from "../../src/logger";
import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/utils";
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
@@ -623,9 +623,7 @@ describe("MatrixClient event timelines", function () {
"GET",
"/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
@@ -1154,10 +1152,7 @@ describe("MatrixClient event timelines", function () {
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name),
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
@@ -1262,11 +1257,8 @@ describe("MatrixClient event timelines", function () {
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({
dir: Direction.Backward,
limit: 3,
recurse: true,
}),
)
@@ -1321,11 +1313,7 @@ describe("MatrixClient event timelines", function () {
function respondToThread(root: Partial<IEvent>, replies: Partial<IEvent>[]): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(root.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
"?dir=b&limit=1",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(root.event_id!) + "?dir=b",
);
request.respond(200, function () {
return {
@@ -1567,7 +1555,7 @@ describe("MatrixClient event timelines", function () {
expect(threadIds).toContain(THREAD2_ROOT.event_id);
const [allThreads] = timelineSets!;
const timeline = allThreads.getLiveTimeline()!;
// Test threads are in chronological order
// Test threads are in chronological order (first thread should be first because it has a more recent reply)
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
@@ -2034,9 +2022,7 @@ describe("MatrixClient event timelines", function () {
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
+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);
});
}
}
+23 -5
View File
@@ -269,7 +269,11 @@ export class MockRTCRtpTransceiver {
}
export class MockMediaStreamTrack {
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {}
constructor(
public readonly id: string,
public readonly kind: "audio" | "video",
public enabled = true,
) {}
public stop = jest.fn<void, []>();
@@ -306,7 +310,10 @@ export class MockMediaStreamTrack {
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
// implementation
export class MockMediaStream {
constructor(public id: string, private tracks: MockMediaStreamTrack[] = []) {}
constructor(
public id: string,
private tracks: MockMediaStreamTrack[] = [],
) {}
public listeners: [string, (...args: any[]) => any][] = [];
public isStopped = false;
@@ -435,7 +442,11 @@ type EmittedEventMap = CallEventHandlerEventHandlerMap &
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
public mediaHandler = new MockMediaHandler();
constructor(public userId: string, public deviceId: string, public sessionId: string) {
constructor(
public userId: string,
public deviceId: string,
public sessionId: string,
) {
super();
}
@@ -502,7 +513,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
}
export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
constructor(public roomId: string, public groupCallId?: string) {
constructor(
public roomId: string,
public groupCallId?: string,
) {
super();
}
@@ -550,7 +564,11 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
}
export class MockCallFeed {
constructor(public userId: string, public deviceId: string | undefined, public stream: MockMediaStream) {}
constructor(
public userId: string,
public deviceId: string | undefined,
public stream: MockMediaStream,
) {}
public measureVolumeActivity(val: boolean) {}
public dispose() {}
+11 -108
View File
@@ -15,13 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import MockHttpBackend from "matrix-mock-request";
import { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src";
import { AutoDiscoveryAction } from "../../src";
import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcError } from "../../src/oidc/error";
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
// keep to reset the fetch function after using MockHttpBackend
// @ts-ignore private property
@@ -351,7 +348,7 @@ describe("AutoDiscovery", function () {
function () {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
not_matrix_versions: ["v1.1"],
not_matrix_versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -388,7 +385,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -409,10 +406,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -428,7 +421,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -450,10 +443,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -469,16 +458,13 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
invalid: true,
},
});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -494,10 +480,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "FAIL_ERROR",
error: OidcError.Misconfigured,
},
};
expect(conf).toEqual(expected);
@@ -515,7 +497,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -560,7 +542,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -606,7 +588,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -653,7 +635,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -697,7 +679,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -728,10 +710,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: "https://identity.example.org",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -747,7 +725,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -784,10 +762,6 @@ describe("AutoDiscovery", function () {
"org.example.custom.property": {
cupcakes: "yes",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -897,75 +871,4 @@ describe("AutoDiscovery", function () {
}),
]);
});
describe("m.authentication", () => {
const homeserverName = "example.org";
const homeserverUrl = "https://chat.example.org/";
const issuer = "https://auth.org/";
beforeAll(() => {
// make these tests independent from fetch mocking above
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
});
beforeEach(() => {
fetchMock.resetBehavior();
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
fetchMock.get("https://example.org/.well-known/matrix/client", {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
issuer,
},
});
});
it("should return valid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.SUCCESS,
...config,
signingKeys: [],
account: undefined,
error: null,
});
});
it("should set state to error for invalid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
// authorization_code is required
config.metadata.grant_types_supported = ["openid"];
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.FAIL_ERROR,
error: OidcError.OpSupport,
});
});
});
});
+15
View File
@@ -37,6 +37,21 @@ describe("ContentRepo", function () {
);
});
it("should allow redirects when requested on download URLs", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, false, true)).toEqual(
baseUrl + "/_matrix/media/v3/download/server.name/resourceid?allow_redirect=true",
);
});
it("should allow redirects when requested on thumbnail URLs", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 32, "scale", false, true)).toEqual(
baseUrl +
"/_matrix/media/v3/thumbnail/server.name/resourceid?width=32&height=32&method=scale&allow_redirect=true",
);
});
it("should return the empty string for null input", function () {
expect(getHttpUriForMxc(null as any, "")).toEqual("");
});
+2 -2
View File
@@ -1263,7 +1263,7 @@ describe("Crypto", function () {
({
init_with_private_key: jest.fn(),
free,
} as unknown as PkDecryption),
}) as unknown as PkDecryption,
);
client.client.checkSecretStoragePrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
@@ -1289,7 +1289,7 @@ describe("Crypto", function () {
({
init_with_seed: jest.fn(),
free,
} as unknown as PkSigning),
}) as unknown as PkSigning,
);
client.client.checkCrossSigningPrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
+2 -2
View File
@@ -106,7 +106,7 @@ describe("Cross Signing", function () {
});
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T>() => ({} as T);
alice.getAccountDataFromServer = async <T>() => ({}) as T;
// set Alice's cross-signing key
await alice.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
@@ -146,7 +146,7 @@ describe("Cross Signing", function () {
};
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({}) as T;
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
await func({});
};
+1 -3
View File
@@ -33,12 +33,10 @@ export async function resetCrossSigningKeys(
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
const decryption = new global.Olm.PkDecryption();
const storagePublicKey = decryption.generate_key();
decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
decryption.free();
return {
// `pubkey` not used anymore with symmetric 4S
keyInfo: { pubkey: storagePublicKey, key: undefined! },
privateKey: storagePrivateKey,
};
}
+1 -7
View File
@@ -190,10 +190,7 @@ describe("Secrets", function () {
};
resetCrossSigningKeys(alice);
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, {
pubkey: undefined,
key: undefined,
});
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key });
// we don't await on this because it waits for the event to come down the sync
// which won't happen in the test setup
alice.setDefaultSecretStorageKeyId(newKeyId);
@@ -335,7 +332,6 @@ describe("Secrets", function () {
it("bootstraps when cross-signing keys in secret storage", async function () {
const decryption = new global.Olm.PkDecryption();
const storagePublicKey = decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
const bob: MatrixClient = await makeTestClient(
@@ -378,8 +374,6 @@ describe("Secrets", function () {
});
await bob.bootstrapSecretStorage({
createSecretStorageKey: async () => ({
// `pubkey` not used anymore with symmetric 4S
keyInfo: { pubkey: storagePublicKey },
privateKey: storagePrivateKey,
}),
});
+226
View File
@@ -0,0 +1,226 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import "jest-localstorage-mock";
import { IndexedDBCryptoStore, LocalStorageCryptoStore, MemoryCryptoStore } from "../../../../src";
import { CryptoStore, MigrationState, SESSION_BATCH_SIZE } from "../../../../src/crypto/store/base";
describe.each([
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
["MemoryCryptoStore", () => new MemoryCryptoStore()],
])("CryptoStore tests for %s", function (name, dbFactory) {
let store: CryptoStore;
beforeEach(async () => {
store = dbFactory();
});
describe("containsData", () => {
it("returns false at first", async () => {
expect(await store.containsData()).toBe(false);
});
it("returns true after startup and account setup", async () => {
await store.startup();
await store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.storeAccount(txn, "not a real account");
});
expect(await store.containsData()).toBe(true);
});
});
describe("migrationState", () => {
beforeEach(async () => {
await store.startup();
});
it("returns 0 at first", async () => {
expect(await store.getMigrationState()).toEqual(MigrationState.NOT_STARTED);
});
it("stores updates", async () => {
await store.setMigrationState(MigrationState.INITIAL_DATA_MIGRATED);
expect(await store.getMigrationState()).toEqual(MigrationState.INITIAL_DATA_MIGRATED);
});
});
describe("get/delete EndToEndSessionsBatch", () => {
beforeEach(async () => {
await store.startup();
});
it("returns null at first", async () => {
expect(await store.getEndToEndSessionsBatch()).toBe(null);
});
it("returns a batch of sessions", async () => {
// First store some sessions in the db
const N_DEVICES = 6;
const N_SESSIONS_PER_DEVICE = 6;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
let nSessions = 0;
await store.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
store.countEndToEndSessions(txn, (n) => (nSessions = n)),
);
expect(nSessions).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
// Then, get a batch and check it looks right.
const batch = await store.getEndToEndSessionsBatch();
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
for (let i = 0; i < N_DEVICES; i++) {
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
const r = batch![i * N_DEVICES + j];
expect(r.deviceKey).toEqual(`device${i}`);
expect(r.sessionId).toEqual(`session${j}`);
}
}
});
it("returns another batch of sessions after the first batch is deleted", async () => {
// First store some sessions in the db
const N_DEVICES = 8;
const N_SESSIONS_PER_DEVICE = 8;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Get the first batch
const batch = (await store.getEndToEndSessionsBatch())!;
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndSessionsBatch(batch);
// Fetch a second batch
const batch2 = (await store.getEndToEndSessionsBatch())!;
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndSessionsBatch(batch2);
// the batch should now be null.
expect(await store.getEndToEndSessionsBatch()).toBe(null);
});
/** Create a bunch of fake Olm sessions and stash them in the DB. */
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_SESSIONS, (txn) => {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndSession(
`device${i}`,
`session${j}`,
{
deviceKey: `device${i}`,
sessionId: `session${j}`,
},
txn,
);
}
}
});
}
});
describe("get/delete EndToEndInboundGroupSessionsBatch", () => {
beforeEach(async () => {
await store.startup();
});
it("returns null at first", async () => {
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
});
it("returns a batch of sessions", async () => {
const N_DEVICES = 6;
const N_SESSIONS_PER_DEVICE = 6;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Mark one of the sessions as needing backup
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_BACKUP, async (txn) => {
await store.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }], txn);
});
expect(await store.countEndToEndInboundGroupSessions()).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
const batch = await store.getEndToEndInboundGroupSessionsBatch();
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
for (let i = 0; i < N_DEVICES; i++) {
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
const r = batch![i * N_DEVICES + j];
expect(r.senderKey).toEqual(pad43(`device${i}`));
expect(r.sessionId).toEqual(`session${j}`);
// only the last session needs backup
expect(r.needsBackup).toBe(i === 5 && j === 5);
}
}
});
it("returns another batch of sessions after the first batch is deleted", async () => {
// First store some sessions in the db
const N_DEVICES = 8;
const N_SESSIONS_PER_DEVICE = 8;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Get the first batch
const batch = (await store.getEndToEndInboundGroupSessionsBatch())!;
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndInboundGroupSessionsBatch(batch);
// Fetch a second batch
const batch2 = (await store.getEndToEndInboundGroupSessionsBatch())!;
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndInboundGroupSessionsBatch(batch2);
// the batch should now be null.
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
});
/** Create a bunch of fake megolm sessions and stash them in the DB. */
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, (txn) => {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndInboundGroupSession(
pad43(`device${i}`),
`session${j}`,
{
forwardingCurve25519KeyChain: [],
keysClaimed: {},
room_id: "",
session: "",
},
txn,
);
}
}
});
}
});
});
/** Pad a string to 43 characters long */
function pad43(x: string): string {
return x + ".".repeat(43 - x.length);
}
@@ -0,0 +1,73 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IndexedDBCryptoStore } from "../../../../src";
import { MigrationState } from "../../../../src/crypto/store/base";
describe("IndexedDBCryptoStore", () => {
describe("Test `existsAndIsNotMigrated`", () => {
beforeEach(async () => {
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
it("Should be true if there is a legacy database", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(true);
});
it("Should be true if there is a legacy database in non migrated state", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
await store.setMigrationState(MigrationState.NOT_STARTED);
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(true);
});
describe.each([
MigrationState.INITIAL_DATA_MIGRATED,
MigrationState.OLM_SESSIONS_MIGRATED,
MigrationState.MEGOLM_SESSIONS_MIGRATED,
MigrationState.ROOM_SETTINGS_MIGRATED,
])("Exists and Migration state is %s", (migrationState) => {
it("Should be false if migration has started", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
await store.setMigrationState(migrationState);
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(false);
});
});
it("Should be false if there is no legacy database", async () => {
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(false);
});
});
});
+56 -28
View File
@@ -55,6 +55,7 @@ import {
RuleId,
IPushRule,
ConditionKind,
getHttpUriForMxc,
} from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
@@ -64,7 +65,7 @@ import {
PolicyScope,
} from "../../src/models/invites-ignorer";
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
import { QueryDict } from "../../src/utils";
import { defer, QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
@@ -369,6 +370,21 @@ describe("MatrixClient", function () {
client.stopClient();
});
describe("mxcUrlToHttp", () => {
it("should call getHttpUriForMxc", () => {
const mxc = "mxc://server/example";
expect(client.mxcUrlToHttp(mxc)).toBe(getHttpUriForMxc(client.baseUrl, mxc));
expect(client.mxcUrlToHttp(mxc, 32)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32));
expect(client.mxcUrlToHttp(mxc, 32, 46)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32, 46));
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale")).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale"),
);
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
);
});
});
describe("timestampToEvent", () => {
const roomId = "!room:server.org";
const eventId = "$eventId:example.org";
@@ -1434,25 +1450,11 @@ describe("MatrixClient", function () {
const mockRoom = {
getMyMembership: () => "join",
updatePendingEvent: (event: MatrixEvent, status: EventStatus) => event.setStatus(status),
currentState: {
getStateEvents: (eventType, stateKey) => {
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
content: {
[RoomCreateTypeField]: RoomType.Space,
},
});
} else if (eventType === EventType.RoomEncryption) {
expect(stateKey).toEqual("");
return new MatrixEvent({ content: {} });
} else {
throw new Error("Unexpected event type or state key");
}
},
} as Room["currentState"],
hasEncryptionStateEvent: jest.fn().mockReturnValue(true),
} as unknown as Room;
let mockCrypto: Mocked<Crypto>;
let event: MatrixEvent;
beforeEach(async () => {
event = new MatrixEvent({
@@ -1467,11 +1469,12 @@ describe("MatrixClient", function () {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
client.crypto = client["cryptoBackend"] = {
// mock crypto
encryptEvent: () => new Promise(() => {}),
mockCrypto = {
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true),
encryptEvent: jest.fn(),
stop: jest.fn(),
} as unknown as Crypto;
} as unknown as Mocked<Crypto>;
client.crypto = client["cryptoBackend"] = mockCrypto;
});
function assertCancelled() {
@@ -1488,12 +1491,21 @@ describe("MatrixClient", function () {
});
it("should cancel an event which is encrypting", async () => {
const encryptEventDefer = defer();
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
const statusPromise = testUtils.emitPromise(event, "Event.status");
// @ts-ignore protected method access
client.encryptAndSendEvent(mockRoom, event);
await testUtils.emitPromise(event, "Event.status");
const encryptAndSendPromise = client.encryptAndSendEvent(mockRoom, event);
await statusPromise;
expect(event.status).toBe(EventStatus.ENCRYPTING);
client.cancelPendingEvent(event);
assertCancelled();
// now let the encryption complete, and check that the message is not sent.
encryptEventDefer.resolve();
await encryptAndSendPromise;
assertCancelled();
});
it("should cancel an event which is not sent", () => {
@@ -2211,8 +2223,7 @@ describe("MatrixClient", function () {
"org.matrix.msc3391": true,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
@@ -2250,8 +2261,7 @@ describe("MatrixClient", function () {
"org.matrix.msc3391": false,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
// populate version support
@@ -3002,4 +3012,22 @@ describe("MatrixClient", function () {
expect(result).toEqual({});
});
});
describe("getAuthIssuer", () => {
it("should use unstable prefix", async () => {
httpLookups = [
{
method: "GET",
path: `/auth_issuer`,
data: {
issuer: "https://issuer/",
},
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
await expect(client.getAuthIssuer()).resolves.toEqual({ issuer: "https://issuer/" });
expect(httpLookups.length).toEqual(0);
});
});
});
+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];
+3 -3
View File
@@ -213,7 +213,7 @@ describe("MSC3089Branch", () => {
expect(eventId).toEqual(fileEventId);
return fileEvent;
},
} as EventTimelineSet);
}) as EventTimelineSet;
client.mxcUrlToHttp = (mxc: string) => {
expect(mxc).toEqual("mxc://" + mxcLatter);
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
@@ -239,7 +239,7 @@ describe("MSC3089Branch", () => {
expect(eventId).toEqual(fileEventId);
return fileEvent;
},
} as EventTimelineSet);
}) as EventTimelineSet;
client.mxcUrlToHttp = (mxc: string) => {
expect(mxc).toEqual("mxc://" + mxcLatter);
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
@@ -332,7 +332,7 @@ describe("MSC3089Branch", () => {
getId: () => "$unknown",
},
];
staticRoom.getLiveTimeline = () => ({ getEvents: () => events } as EventTimeline);
staticRoom.getLiveTimeline = () => ({ getEvents: () => events }) as EventTimeline;
directory.getFile = (evId: string) => {
expect(evId).toEqual(fileEventId);
+2 -2
View File
@@ -399,7 +399,7 @@ describe("MSC3089TreeSpace", () => {
];
},
};
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
const getFn = jest.fn().mockImplementation((roomId: string) => {
if (roomId === thirdChildRoom) {
@@ -422,7 +422,7 @@ describe("MSC3089TreeSpace", () => {
});
it("should find specific directories", () => {
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
// Only mocking used API
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
+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);
+4 -1
View File
@@ -32,7 +32,10 @@ export class DummyTransport<D extends RendezvousTransportDetails, T> implements
ready = false;
cancelled = false;
constructor(private name: string, private mockDetails: D) {}
constructor(
private name: string,
private mockDetails: D,
) {}
onCancelled?: RendezvousFailureListener;
details(): Promise<RendezvousTransportDetails> {
+143 -77
View File
@@ -23,7 +23,7 @@ import {
MSC3903ECDHPayload,
MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel,
} from "../../../src/rendezvous/channels";
import { MatrixClient } from "../../../src";
import { Device, MatrixClient } from "../../../src";
import {
MSC3886SimpleHttpRendezvousTransport,
MSC3886SimpleHttpRendezvousTransportDetails,
@@ -31,16 +31,57 @@ import {
import { DummyTransport } from "./DummyTransport";
import { decodeBase64 } from "../../../src/base64";
import { logger } from "../../../src/logger";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { CrossSigningKey, OwnDeviceKeys } from "../../../src/crypto-api";
type UserID = string;
type DeviceID = string;
type Fingerprint = string;
type SimpleDeviceMap = Record<UserID, Record<DeviceID, Fingerprint>>;
function mockDevice(userId: UserID, deviceId: DeviceID, fingerprint: Fingerprint): Device {
return {
deviceId,
userId,
getFingerprint: () => fingerprint,
} as unknown as Device;
}
function mockDeviceMap(
userId: UserID,
deviceId: DeviceID,
deviceKey?: Fingerprint,
otherDevices: SimpleDeviceMap = {},
): Map<string, Map<string, Device>> {
const deviceMap: Map<string, Map<string, Device>> = new Map();
const myDevices: Map<string, Device> = new Map();
if (deviceKey) {
myDevices.set(deviceId, mockDevice(userId, deviceId, deviceKey));
}
deviceMap.set(userId, myDevices);
for (const u in otherDevices) {
let userDevices = deviceMap.get(u);
if (!userDevices) {
userDevices = new Map();
deviceMap.set(u, userDevices);
}
for (const d in otherDevices[u]) {
userDevices.set(d, mockDevice(u, d, otherDevices[u][d]));
}
}
return deviceMap;
}
function makeMockClient(opts: {
userId: string;
deviceId: string;
deviceKey?: string;
userId: UserID;
deviceId: DeviceID;
deviceKey?: Fingerprint;
getLoginTokenEnabled: boolean;
msc3882r0Only: boolean;
msc3886Enabled: boolean;
devices?: Record<string, Partial<DeviceInfo>>;
devices?: SimpleDeviceMap;
verificationFunction?: (
userId: string,
deviceId: string,
@@ -48,50 +89,77 @@ function makeMockClient(opts: {
blocked: boolean,
known: boolean,
) => void;
crossSigningIds?: Record<string, string>;
}): MatrixClient {
return {
getVersions() {
return {
unstable_features: {
"org.matrix.msc3882": opts.getLoginTokenEnabled,
"org.matrix.msc3886": opts.msc3886Enabled,
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"m.get_login_token": {
enabled: opts.getLoginTokenEnabled,
crossSigningIds?: Partial<Record<CrossSigningKey, string>>;
}): [MatrixClient, Map<string, Map<string, Device>>] {
const deviceMap = mockDeviceMap(opts.userId, opts.deviceId, opts.deviceKey, opts.devices);
return [
{
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
if (feature === "org.matrix.msc3886") {
return opts.msc3886Enabled;
} else if (feature === "org.matrix.msc3882") {
return opts.getLoginTokenEnabled;
} else {
return false;
}
}),
getVersions() {
return {
unstable_features: {
"org.matrix.msc3882": opts.getLoginTokenEnabled,
"org.matrix.msc3886": opts.msc3886Enabled,
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"m.get_login_token": {
enabled: opts.getLoginTokenEnabled,
},
},
},
};
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
getDeviceEd25519Key() {
return opts.deviceKey;
},
baseUrl: "https://example.com",
crypto: {
getStoredDevice(userId: string, deviceId: string) {
return opts.devices?.[deviceId] ?? null;
};
},
setDeviceVerification: opts.verificationFunction,
crossSigningInfo: {
getId(key: string) {
return opts.crossSigningIds?.[key];
},
getUserId() {
return opts.userId;
},
},
} as unknown as MatrixClient;
getSafeUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl: "https://example.com",
getCrypto() {
return {
getUserDeviceInfo(
[userId]: string[],
downloadUncached?: boolean,
): Promise<Map<string, Map<string, Device>>> {
return Promise.resolve(deviceMap);
},
getCrossSigningKeyId(key: CrossSigningKey): string | null {
return opts.crossSigningIds?.[key] ?? null;
},
setDeviceVerified(userId: string, deviceId: string, verified: boolean): Promise<void> {
return Promise.resolve();
},
crossSignDevice(deviceId: string): Promise<void> {
return Promise.resolve();
},
getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
return Promise.resolve({
ed25519: opts.deviceKey!,
curve25519: "aaaa",
});
},
};
},
} as unknown as MatrixClient,
deviceMap,
];
}
function makeTransport(name: string, uri = "https://test.rz/123456") {
@@ -106,6 +174,7 @@ describe("Rendezvous", function () {
let httpBackend: MockHttpBackend;
let fetchFn: typeof global.fetch;
let transports: DummyTransport<any, MSC3903ECDHPayload>[];
const userId: UserID = "@user:example.com";
beforeEach(function () {
httpBackend = new MockHttpBackend();
@@ -118,9 +187,9 @@ describe("Rendezvous", function () {
});
it("generate and cancel", async function () {
const alice = makeMockClient({
userId: "@alice:example.com",
deviceId: "DEVICEID",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
msc3886Enabled: false,
getLoginTokenEnabled: true,
msc3882r0Only: true,
@@ -194,8 +263,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
msc3886Enabled: false,
getLoginTokenEnabled,
@@ -257,8 +326,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -316,8 +385,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -375,7 +444,7 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
const [alice] = makeMockClient({
userId: "alice",
deviceId: "ALICE",
getLoginTokenEnabled: true,
@@ -436,7 +505,7 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
const [alice] = makeMockClient({
userId: "alice",
deviceId: "ALICE",
getLoginTokenEnabled: true,
@@ -495,7 +564,7 @@ describe("Rendezvous", function () {
await bobCompleteProm;
});
async function completeLogin(devices: Record<string, Partial<DeviceInfo>>) {
async function completeLogin(devices: SimpleDeviceMap) {
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -505,8 +574,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const aliceVerification = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice, deviceMap] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -575,13 +644,14 @@ describe("Rendezvous", function () {
aliceRz,
bobTransport,
bobEcdh,
deviceMap,
};
}
it("approve on existing device + verification", async function () {
const { bobEcdh, aliceRz } = await completeLogin({
BOB: {
getFingerprint: () => "bbbb",
[userId]: {
BOB: "bbbb",
},
});
const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice();
@@ -607,33 +677,29 @@ describe("Rendezvous", function () {
});
it("device appears online within timeout", async function () {
const devices: Record<string, Partial<DeviceInfo>> = {};
const { aliceRz } = await completeLogin(devices);
// device appears after 1 second
const devices: SimpleDeviceMap = {};
const { aliceRz, deviceMap } = await completeLogin(devices);
// device appears before the timeout
setTimeout(() => {
devices.BOB = {
getFingerprint: () => "bbbb",
};
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
}, 1000);
await aliceRz.verifyNewDeviceOnExistingDevice(2000);
});
it("device appears online after timeout", async function () {
const devices: Record<string, Partial<DeviceInfo>> = {};
const { aliceRz } = await completeLogin(devices);
// device appears after 1 second
const devices: SimpleDeviceMap = {};
const { aliceRz, deviceMap } = await completeLogin(devices);
// device appears after the timeout
setTimeout(() => {
devices.BOB = {
getFingerprint: () => "bbbb",
};
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
}, 1500);
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("mismatched device key", async function () {
const { aliceRz } = await completeLogin({
BOB: {
getFingerprint: () => "XXXX",
[userId]: {
BOB: "XXXX",
},
});
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
+8 -8
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:
@@ -3315,7 +3315,7 @@ describe("Room", function () {
return event1 === `eventId${i}` ? 1 : -1;
},
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
@@ -3328,7 +3328,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
@@ -3348,7 +3348,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
@@ -3366,7 +3366,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
});
it("should give precedence to m.read.private", () => {
+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();
});
});
});
});
+163 -10
View File
@@ -16,16 +16,169 @@
* /
*/
import { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
Curve25519PublicKey,
Ed25519PublicKey,
HistoryVisibility as RustHistoryVisibility,
IdentityKeys,
OlmMachine,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { Mocked } from "jest-mock";
import { HistoryVisibility } from "../../../src";
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
import { HistoryVisibility, MatrixEvent, Room, RoomMember } from "../../../src";
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
import { defer } from "../../../src/utils";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
it.each([
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
it.each([
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
});
});
describe("RoomEncryptor", () => {
/** The room encryptor under test */
let roomEncryptor: RoomEncryptor;
let mockOlmMachine: Mocked<OlmMachine>;
let mockKeyClaimManager: Mocked<KeyClaimManager>;
let mockOutgoingRequestManager: Mocked<OutgoingRequestsManager>;
let mockRoom: Mocked<Room>;
const mockRoomMember = {
userId: "@alice:example.org",
membership: "join",
} as unknown as Mocked<RoomMember>;
function createMockEvent(text: string): Mocked<MatrixEvent> {
return {
getTxnId: jest.fn().mockReturnValue(""),
getType: jest.fn().mockReturnValue("m.room.message"),
getContent: jest.fn().mockReturnValue({
body: text,
msgtype: "m.text",
}),
makeEncrypted: jest.fn().mockReturnValue(undefined),
} as unknown as Mocked<MatrixEvent>;
}
beforeEach(() => {
mockOlmMachine = {
identityKeys: {
curve25519: {
toBase64: jest.fn().mockReturnValue("curve25519"),
} as unknown as Curve25519PublicKey,
ed25519: {
toBase64: jest.fn().mockReturnValue("ed25519"),
} as unknown as Ed25519PublicKey,
} as unknown as Mocked<IdentityKeys>,
shareRoomKey: jest.fn(),
updateTrackedUsers: jest.fn().mockResolvedValue(undefined),
encryptRoomEvent: jest.fn().mockResolvedValue("{}"),
} as unknown as Mocked<OlmMachine>;
mockKeyClaimManager = {
ensureSessionsForUsers: jest.fn(),
} as unknown as Mocked<KeyClaimManager>;
mockOutgoingRequestManager = {
doProcessOutgoingRequests: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<OutgoingRequestsManager>;
mockRoom = {
roomId: "!foo:example.org",
getJoinedMembers: jest.fn().mockReturnValue([mockRoomMember]),
getEncryptionTargetMembers: jest.fn().mockReturnValue([mockRoomMember]),
shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(true),
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Invited),
getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false),
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
mockRoom,
{ algorithm: "m.megolm.v1.aes-sha2" },
);
});
it("should ensure that there is only one shareRoomKey at a time", async () => {
const deferredShare = defer<void>();
const insideOlmShareRoom = defer<void>();
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
insideOlmShareRoom.resolve();
await deferredShare.promise;
});
roomEncryptor.prepareForEncryption(false);
await insideOlmShareRoom.promise;
// call several times more
roomEncryptor.prepareForEncryption(false);
roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
roomEncryptor.prepareForEncryption(false);
roomEncryptor.encryptEvent(createMockEvent("World"), false);
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(1);
deferredShare.resolve();
await roomEncryptor.prepareForEncryption(false);
// should have been called again
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(6);
});
// Regression test for https://github.com/element-hq/element-web/issues/26684
it("Should maintain order of encryption requests", async () => {
const firstTargetMembers = defer<void>();
const secondTargetMembers = defer<void>();
mockOlmMachine.shareRoomKey.mockResolvedValue(undefined);
// Hook into this method to demonstrate the race condition
mockRoom.getEncryptionTargetMembers
.mockImplementationOnce(async () => {
await firstTargetMembers.promise;
return [mockRoomMember];
})
.mockImplementationOnce(async () => {
await secondTargetMembers.promise;
return [mockRoomMember];
});
let firstMessageFinished: string | null = null;
const firstRequest = roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
const secondRequest = roomEncryptor.encryptEvent(createMockEvent("Edit of Hello"), false);
firstRequest.then(() => {
if (firstMessageFinished === null) {
firstMessageFinished = "hello";
}
});
secondRequest.then(() => {
if (firstMessageFinished === null) {
firstMessageFinished = "edit";
}
});
// suppose the second getEncryptionTargetMembers call returns first
secondTargetMembers.resolve();
firstTargetMembers.resolve();
await Promise.all([firstRequest, secondRequest]);
expect(firstMessageFinished).toBe("hello");
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { CryptoEvent, HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, TypedEventEmitter } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import * as testData from "../../test-utils/test-data";
import * as TestData from "../../test-utils/test-data";
import { IKeyBackup } from "../../../src/crypto/backup";
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
import { RustBackupManager } from "../../../src/rust-crypto/backup";
describe("Upload keys to backup", () => {
/** The backup manager under test */
let rustBackupManager: RustBackupManager;
let mockOlmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
const httpAPi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
let idGenerator = 0;
function mockBackupRequest(keyCount: number): RustSdkCryptoJs.KeysBackupRequest {
const requestBody: IKeyBackup = {
rooms: {
"!room1:server": {
sessions: {},
},
},
};
for (let i = 0; i < keyCount; i++) {
requestBody.rooms["!room1:server"].sessions["session" + i] = {} as IKeyBackupSession;
}
return {
id: "id" + idGenerator++,
body: JSON.stringify(requestBody),
} as unknown as Mocked<RustSdkCryptoJs.KeysBackupRequest>;
}
beforeEach(async () => {
jest.useFakeTimers();
idGenerator = 0;
mockOlmMachine = {
getBackupKeys: jest.fn().mockResolvedValue({
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys),
backupRoomKeys: jest.fn(),
isBackupEnabled: jest.fn().mockResolvedValue(true),
enableBackupV1: jest.fn(),
verifyBackup: jest.fn().mockResolvedValue({
trusted: jest.fn().mockResolvedValue(true),
} as unknown as RustSdkCryptoJs.SignatureVerification),
roomKeyCounts: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
});
afterEach(() => {
fetchMock.reset();
jest.useRealTimers();
jest.resetAllMocks();
});
it("Should call expensive roomKeyCounts only once per loop", async () => {
const remainingEmitted: number[] = [];
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
remainingEmitted.push(count);
if (count == 0) {
resolve();
}
});
});
// We want several batch of keys to check that we don't call expensive room key count several times
mockOlmMachine.backupRoomKeys
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(2))
.mockResolvedValue(null);
mockOlmMachine.roomKeyCounts.mockResolvedValue({
total: 602,
// First iteration won't call roomKeyCounts(); it will be called on the second iteration after 200 keys have been saved.
backedUp: 200,
});
await rustBackupManager.checkKeyBackupAndEnable(false);
await jest.runAllTimersAsync();
await zeroRemainingWasEmitted;
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(7);
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(1);
// check event emission
expect(remainingEmitted[0]).toEqual(402);
expect(remainingEmitted[1]).toEqual(302);
expect(remainingEmitted[2]).toEqual(202);
expect(remainingEmitted[3]).toEqual(102);
expect(remainingEmitted[4]).toEqual(2);
expect(remainingEmitted[5]).toEqual(0);
});
it("Should not call expensive roomKeyCounts when only one iteration is needed", async () => {
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
if (count == 0) {
resolve();
}
});
});
// Only returns 2 keys on the first call, then none.
mockOlmMachine.backupRoomKeys.mockResolvedValueOnce(mockBackupRequest(2)).mockResolvedValue(null);
await rustBackupManager.checkKeyBackupAndEnable(false);
await jest.runAllTimersAsync();
await zeroRemainingWasEmitted;
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(0);
});
});
+538 -63
View File
@@ -15,7 +15,15 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
BaseMigrationData,
KeysQueryRequest,
Migration,
OlmMachine,
PickledInboundGroupSession,
PickledSession,
StoreHandle,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked, Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
@@ -25,6 +33,7 @@ import {
CryptoEvent,
Device,
DeviceVerification,
encodeBase64,
HttpApiEvent,
HttpApiEventHandlerMap,
IHttpOpts,
@@ -32,13 +41,20 @@ import {
MatrixClient,
MatrixEvent,
MatrixHttpApi,
MemoryCryptoStore,
TypedEventEmitter,
} from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
import {
AccountDataClient,
AddSecretStorageKeyOpts,
SecretStorageCallbacks,
ServerSideSecretStorage,
ServerSideSecretStorageImpl,
} from "../../../src/secret-storage";
import {
CryptoCallbacks,
EventShieldColour,
@@ -51,11 +67,20 @@ import * as testData from "../../test-utils/test-data";
import { defer } from "../../../src/utils";
import { logger } from "../../../src/logger";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
import { ClientEvent, ClientEventHandlerMap } from "../../../src/client";
import { Curve25519AuthData } from "../../../src/crypto-api/keybackup";
import { encryptAES } from "../../../src/crypto/aes";
import { CryptoStore, SecretStorePrivateKeys } from "../../../src/crypto/store/base";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
beforeAll(async () => {
// Load the WASM upfront, before any of the tests. This can take some time, and doing it here means that it gets
// a separate timeout.
await RustSdkCryptoJs.initAsync();
}, 15000);
afterEach(() => {
fetchMock.reset();
jest.restoreAllMocks();
@@ -66,71 +91,287 @@ describe("initRustCrypto", () => {
return {
registerRoomKeyUpdatedCallback: jest.fn(),
registerUserIdentityUpdatedCallback: jest.fn(),
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
getSecretsFromInbox: jest.fn().mockResolvedValue([]),
deleteSecretsFromInbox: jest.fn(),
registerReceiveSecretCallback: jest.fn(),
registerDevicesUpdatedCallback: jest.fn(),
outgoingRequests: jest.fn(),
isBackupEnabled: jest.fn().mockResolvedValue(false),
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(false) }),
getBackupKeys: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
it("passes through the store params", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto(
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
"storePrefix",
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
});
expect(OlmMachine.initialize).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"storePrefix",
"storePassphrase",
);
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("suppresses the storePassphrase if storePrefix is unset", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto(
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
null,
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: null,
storePassphrase: "storePassphrase",
});
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
expect(StoreHandle.open).toHaveBeenCalledWith(undefined, undefined);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("Should get secrets from inbox on start", async () => {
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
await initRustCrypto(
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
"storePrefix",
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
});
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
});
describe("libolm migration", () => {
let mockStore: RustSdkCryptoJs.StoreHandle;
beforeEach(() => {
// Stub out a bunch of stuff in the Rust library
mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
jest.spyOn(Migration, "migrateBaseData").mockResolvedValue(undefined);
jest.spyOn(Migration, "migrateOlmSessions").mockResolvedValue(undefined);
jest.spyOn(Migration, "migrateMegolmSessions").mockResolvedValue(undefined);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
});
it("migrates data from a legacy crypto store", async () => {
const PICKLE_KEY = "pickle1234";
const legacyStore = new MemoryCryptoStore();
// Populate the legacy store with some test data
const storeSecretKey = (type: string, key: string) =>
encryptAndStoreSecretKey(type, new TextEncoder().encode(key), PICKLE_KEY, legacyStore);
await legacyStore.storeAccount({}, "not a real account");
await storeSecretKey("m.megolm_backup.v1", "backup key");
await storeSecretKey("master", "master key");
await storeSecretKey("self_signing", "ssk");
await storeSecretKey("user_signing", "usk");
const nDevices = 6;
const nSessionsPerDevice = 10;
createSessions(legacyStore, nDevices, nSessionsPerDevice);
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
}
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
legacyCryptoStore: legacyStore,
legacyPickleKey: PICKLE_KEY,
legacyMigrationProgressListener,
});
// Check that the migration functions were correctly called
expect(Migration.migrateBaseData).toHaveBeenCalledWith(
expect.any(BaseMigrationData),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
expect(data.pickledAccount).toEqual("not a real account");
expect(data.userId!.toString()).toEqual(TEST_USER);
expect(data.deviceId!.toString()).toEqual(TEST_DEVICE_ID);
expect(atob(data.backupRecoveryKey!)).toEqual("backup key");
expect(data.backupVersion).toEqual("45");
expect(atob(data.privateCrossSigningMasterKey!)).toEqual("master key");
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
expect(Migration.migrateOlmSessions).toHaveBeenCalledTimes(2);
expect(Migration.migrateOlmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
// First call should have 50 entries; second should have 10
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
expect(sessions1.length).toEqual(50);
const sessions2: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[1][0];
expect(sessions2.length).toEqual(10);
const sessions = [...sessions1, ...sessions2];
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const session = sessions[i * nSessionsPerDevice + j];
expect(session.senderKey).toEqual(`device${i}`);
expect(session.pickle).toEqual(`session${i}.${j}`);
expect(session.creationTime).toEqual(new Date(1000));
expect(session.lastUseTime).toEqual(new Date(1000));
}
}
expect(Migration.migrateMegolmSessions).toHaveBeenCalledTimes(2);
expect(Migration.migrateMegolmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
// First call should have 50 entries; second should have 10
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[0][0];
expect(megolmSessions1.length).toEqual(50);
const megolmSessions2: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[1][0];
expect(megolmSessions2.length).toEqual(10);
const megolmSessions = [...megolmSessions1, ...megolmSessions2];
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const session = megolmSessions[i * nSessionsPerDevice + j];
expect(session.senderKey).toEqual(pad43(`device${i}`));
expect(session.pickle).toEqual("sessionPickle");
expect(session.roomId!.toString()).toEqual("!room:id");
expect(session.senderSigningKey).toEqual("sender_signing_key");
// only one of the sessions needs backing up
expect(session.backedUp).toEqual(i !== 5 || j !== 5);
}
}
}, 10000);
it("handles megolm sessions with no `keysClaimed`", async () => {
const legacyStore = new MemoryCryptoStore();
legacyStore.storeAccount({}, "not a real account");
legacyStore.storeEndToEndInboundGroupSession(
pad43(`device1`),
`session1`,
{
forwardingCurve25519KeyChain: [],
room_id: "!room:id",
session: "sessionPickle",
},
undefined,
);
const PICKLE_KEY = "pickle1234";
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
legacyCryptoStore: legacyStore,
legacyPickleKey: PICKLE_KEY,
});
expect(Migration.migrateMegolmSessions).toHaveBeenCalledTimes(1);
expect(Migration.migrateMegolmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
const megolmSessions: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[0][0];
expect(megolmSessions.length).toEqual(1);
const session = megolmSessions[0];
expect(session.senderKey).toEqual(pad43(`device1`));
expect(session.pickle).toEqual("sessionPickle");
expect(session.roomId!.toString()).toEqual("!room:id");
expect(session.senderSigningKey).toBe(undefined);
}, 10000);
async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) {
const encryptedKey = await encryptAES(encodeBase64(key), Buffer.from(pickleKey), type);
store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey);
}
/** Create a bunch of fake Olm sessions and stash them in the DB. */
function createSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const sessionData = {
deviceKey: `device${i}`,
sessionId: `session${j}`,
session: `session${i}.${j}`,
lastReceivedMessageTs: 1000,
};
store.storeEndToEndSession(`device${i}`, `session${j}`, sessionData, undefined);
}
}
}
/** Create a bunch of fake Megolm sessions and stash them in the DB. */
function createMegolmSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndInboundGroupSession(
pad43(`device${i}`),
`session${j}`,
{
forwardingCurve25519KeyChain: [],
keysClaimed: { ed25519: "sender_signing_key" },
room_id: "!room:id",
session: "sessionPickle",
},
undefined,
);
}
}
}
});
});
describe("RustCrypto", () => {
@@ -142,7 +383,7 @@ describe("RustCrypto", () => {
);
});
describe(".importRoomKeys and .exportRoomKeys", () => {
describe("importing and exporting room keys", () => {
let rustCrypto: RustCrypto;
beforeEach(
@@ -158,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);
@@ -175,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", () => {
@@ -294,6 +558,62 @@ describe("RustCrypto", () => {
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
});
it("bootstrapSecretStorage creates new backup when requested", async () => {
const secretStorageCallbacks = {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
} as SecretStorageCallbacks;
const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks);
const outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
const rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
}),
testData.TEST_USER_ID,
undefined,
secretStorage,
);
rustCrypto["checkKeyBackupAndEnable"] = async () => {
return null;
};
(rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor;
const resetKeyBackup = (rustCrypto["resetKeyBackup"] = jest.fn());
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
// create initial secret storage
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
await rustCrypto.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: true,
});
// check that rustCrypto.resetKeyBackup was called
expect(resetKeyBackup.mock.calls).toHaveLength(1);
// reset secret storage
await rustCrypto.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: true,
});
// check that rustCrypto.resetKeyBackup was called again
expect(resetKeyBackup.mock.calls).toHaveLength(2);
});
it("isSecretStorageReady", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue(null),
@@ -400,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", () => {
@@ -421,7 +793,7 @@ describe("RustCrypto", () => {
decryptEvent: () =>
({
senderCurve25519Key: "1234",
} as IEventDecryptionResult),
}) as IEventDecryptionResult,
} as unknown as CryptoBackend;
await event.attemptDecryption(mockCryptoBackend);
@@ -461,7 +833,7 @@ describe("RustCrypto", () => {
decryptEvent: () =>
({
clearEvent: { content: { body: "1234" } },
} as unknown as IEventDecryptionResult),
}) as unknown as IEventDecryptionResult,
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(mockCryptoBackend);
return encryptedEvent;
@@ -739,8 +1111,8 @@ describe("RustCrypto", () => {
// Expect the private key to be an Uint8Array with a length of 32
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
expect(recoveryKey.privateKey.length).toBe(32);
// Expect keyInfo to be empty
expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0);
// Expect passphrase info to be absent
expect(recoveryKey.keyInfo?.passphrase).toBeUndefined();
});
it("should create a recovery key with password", async () => {
@@ -758,13 +1130,9 @@ describe("RustCrypto", () => {
});
it("should wait for a keys/query before returning devices", async () => {
jest.useFakeTimers();
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
@@ -774,7 +1142,7 @@ describe("RustCrypto", () => {
},
});
const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID);
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), testData.TEST_USER_ID);
// an attempt to fetch the device list should block
const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
@@ -788,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();
@@ -900,12 +1295,6 @@ describe("RustCrypto", () => {
// Return the key backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
const olmMachine = {
getIdentity: jest.fn(),
// Force the backup to be trusted by the olmMachine
@@ -918,7 +1307,7 @@ describe("RustCrypto", () => {
const rustCrypto = new RustCrypto(
logger,
olmMachine,
mockHttpApi,
makeMatrixHttpApi(),
testData.TEST_USER_ID,
testData.TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
@@ -974,9 +1363,46 @@ 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,
});
});
});
});
/** Build a MatrixHttpApi instance */
function makeMatrixHttpApi(): MatrixHttpApi<IHttpOpts & { onlyData: true }> {
return new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
}
/** build a basic RustCrypto instance for testing
*
* just provides default arguments for initRustCrypto()
@@ -988,5 +1414,54 @@ async function makeTestRustCrypto(
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
return await initRustCrypto({
logger,
http,
userId,
deviceId,
secretStorage,
cryptoCallbacks,
storePrefix: null,
storePassphrase: undefined,
});
}
/** emulate account data, storing in memory
*/
class DummyAccountDataClient
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
implements AccountDataClient
{
private storage: Map<string, any> = new Map();
public constructor() {
super();
}
public async getAccountDataFromServer<T extends Record<string, any>>(eventType: string): Promise<T | null> {
const ret = this.storage.get(eventType);
if (eventType) {
return ret as T;
} else {
return null;
}
}
public async setAccountData(eventType: string, content: any): Promise<{}> {
this.storage.set(eventType, content);
this.emit(
ClientEvent.AccountData,
new MatrixEvent({
content,
type: eventType,
}),
);
return {};
}
}
/** Pad a string to 43 characters long */
function pad43(x: string): string {
return x + ".".repeat(43 - x.length);
}
+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;
},
};
}
+18 -4
View File
@@ -33,7 +33,9 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a default key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
key: new Uint8Array(32),
});
// it should have made up a 32-character key id
expect(result.keyId.length).toEqual(32);
@@ -46,7 +48,13 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a key with an explicit id", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
const result = await secretStorage.addKey(
"m.secret_storage.v1.aes-hmac-sha2",
{
key: new Uint8Array(32),
},
"myKeyId",
);
// it should have made up a 32-character key id
expect(result.keyId).toEqual("myKeyId");
@@ -59,7 +67,10 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a key with a name", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
name: "mykey",
key: new Uint8Array(32),
});
expect(result.keyInfo.name).toEqual("mykey");
@@ -80,6 +91,7 @@ describe("ServerSideSecretStorageImpl", function () {
};
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
passphrase,
key: new Uint8Array(32),
});
expect(result.keyInfo.passphrase).toEqual(passphrase);
@@ -93,7 +105,9 @@ describe("ServerSideSecretStorageImpl", function () {
it("should complain about invalid algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
await expect(() => secretStorage.addKey("bad_alg", { key: new Uint8Array(32) })).rejects.toThrow(
"Unknown key algorithm",
);
});
});
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEvent } from "../../src";
import { randomString } from "../../src/randomstring";
import { getRelationsThreadFilter } from "../../src/thread-utils";
function makeEvent(relatesToEvent: string, relType: string): Partial<IEvent> {
return {
event_id: randomString(10),
type: "m.room.message",
content: {
"msgtype": "m.text",
"body": "foo",
"m.relates_to": {
rel_type: relType,
event_id: relatesToEvent,
},
},
};
}
describe("getRelationsThreadFilter", () => {
it("should filter out relations directly to the thread root event", () => {
const threadId = "thisIsMyThreadRoot";
const reactionToRoot = makeEvent(threadId, "m.annotation");
const editToRoot = makeEvent(threadId, "m.replace");
const firstThreadedReply = makeEvent(threadId, "m.thread");
const reactionToThreadedEvent = makeEvent(firstThreadedReply.event_id!, "m.annotation");
const filteredEvents = [reactionToRoot, editToRoot, firstThreadedReply, reactionToThreadedEvent].filter(
getRelationsThreadFilter(threadId),
);
expect(filteredEvents).toEqual([firstThreadedReply, reactionToThreadedEvent]);
});
});
+4 -4
View File
@@ -954,7 +954,7 @@ describe("Group Call", function () {
},
},
}),
} as MatrixEvent);
}) as MatrixEvent;
it("should mute remote feed's audio after receiving metadata with video audio", async () => {
const metadataEvent = getMetadataEvent(true, false);
@@ -965,7 +965,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(
// @ts-ignore Mock
@@ -992,7 +992,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(
// @ts-ignore Mock
@@ -1310,7 +1310,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
call.onNegotiateReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
+17
View File
@@ -0,0 +1,17 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type NonEmptyArray<T> = [T, ...T[]];
+2 -3
View File
@@ -23,9 +23,8 @@ import { isProvided } from "../extensible_events_v1/utilities";
/**
* Represents the stable and unstable values of a given namespace.
*/
export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U>
? TSNamespaceValue<S> | TSNamespaceValue<U>
: never;
export type TSNamespace<N> =
N extends NamespacedValue<infer S, infer U> ? TSNamespaceValue<S> | TSNamespaceValue<U> : never;
/**
* Represents a namespaced value, if the value is a string. Used to extract provided types
+4 -1
View File
@@ -26,7 +26,10 @@ export class NamespacedValue<S extends string, U extends string> {
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(public readonly stable?: S | null, public readonly unstable?: U) {
public constructor(
public readonly stable?: S | null,
public readonly unstable?: U,
) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
}
+24 -132
View File
@@ -15,20 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { SigningKey } from "oidc-client-ts";
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { IClientWellKnown, IWellKnownConfig, IServerVersions } from "./client";
import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api";
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
import {
ValidatedIssuerConfig,
ValidatedIssuerMetadata,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
import { OidcError } from "./oidc/error";
import { MINIMUM_MATRIX_VERSION } from "./version-support";
import { SUPPORTED_MATRIX_VERSIONS } from "./version-support";
// Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -51,7 +41,10 @@ export enum AutoDiscoveryError {
InvalidIs = "Invalid identity server discovery response",
MissingWellknown = "No .well-known JSON file found",
InvalidJson = "Invalid JSON",
HomeserverTooOld = "The homeserver does not meet the minimum version requirements",
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
/** @deprecated Replaced by `UnsupportedHomeserverSpecVersion` */
HomeserverTooOld = UnsupportedHomeserverSpecVersion,
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
}
@@ -62,26 +55,9 @@ interface AutoDiscoveryState {
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
/**
* @deprecated in favour of OidcClientConfig
*/
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
/**
* @experimental
*/
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
/**
* @experimental
*/
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
}
/**
@@ -112,7 +88,11 @@ export class AutoDiscovery {
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
/** @deprecated Replaced by ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION */
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
@@ -216,10 +196,19 @@ export class AutoDiscovery {
return Promise.resolve(clientConfig);
}
// Step 3.1: Non-spec check to ensure the server will actually work for us
if (!hsVersions.raw!["versions"].includes(MINIMUM_MATRIX_VERSION)) {
logger.error("Homeserver does not meet minimum version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_HOMESERVER_TOO_OLD;
// Step 3.1: Non-spec check to ensure the server will actually work for us. We need to check if
// any of the versions in `SUPPORTED_MATRIX_VERSIONS` are listed in the /versions response.
const hsVersionSet = new Set(hsVersions.raw!["versions"]);
let supportedVersionFound = false;
for (const version of SUPPORTED_MATRIX_VERSIONS) {
if (hsVersionSet.has(version)) {
supportedVersionFound = true;
break;
}
}
if (!supportedVersionFound) {
logger.error("Homeserver does not meet version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
// Supply the base_url to the caller because they may be ignoring liveliness
// errors, like this one.
@@ -302,107 +291,10 @@ export class AutoDiscovery {
}
});
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
}
/**
* Validate delegated auth configuration
* @deprecated use discoverAndValidateAuthenticationConfig
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async validateDiscoveryAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Failed to fetch issuer openid configuration");
throw new Error(OidcError.General);
}
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
const delegatedAuthConfig: DelegatedAuthConfig = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
};
return delegatedAuthConfig;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, validated authentication metadata and optionally signing keys will be returned
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async discoverAndValidateAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const result = await discoverAndValidateAuthenticationConfig(authentication);
// include this for backwards compatibility
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
const response = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...validatedIssuerConfig,
...result,
};
return response;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL
+315 -151
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";
@@ -221,6 +221,7 @@ import {
} from "./secret-storage";
import { RegisterRequest, RegisterResponse } from "./@types/registration";
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
import { getRelationsThreadFilter } from "./thread-utils";
export type Store = IStore;
@@ -624,13 +625,10 @@ export interface IServerVersions {
unstable_features: Record<string, boolean>;
}
export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
export interface IClientWellKnown {
[key: string]: any;
"m.homeserver"?: IWellKnownConfig;
"m.identity_server"?: IWellKnownConfig;
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
}
export interface IWellKnownConfig<T = IClientWellKnown> {
@@ -644,14 +642,6 @@ export interface IWellKnownConfig<T = IClientWellKnown> {
server_name?: string;
}
export interface IDelegatedAuthConfig {
// MSC2965
/** The OIDC Provider/issuer the client should use */
issuer: string;
/** The optional URL of the web UI where the user can manage their account */
account?: string;
}
interface IKeyBackupPath {
path: string;
queryData?: {
@@ -961,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;
@@ -1303,7 +1294,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this);
protected sessionId: string;
protected pendingEventEncryption = new Map<string, Promise<void>>();
/** IDs of events which are currently being encrypted.
*
* This is part of the cancellation mechanism: if the event is no longer listed here when encryption completes,
* that tells us that it has been cancelled, and we should not send it.
*/
private eventsBeingEncrypted = new Set<string>();
private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue;
@@ -1443,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 =
@@ -2315,17 +2312,25 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
this.logger.debug("Downloading Rust crypto library");
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(
this.logger,
this.http,
userId,
deviceId,
this.secretStorage,
this.cryptoCallbacks,
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
this.pickleKey,
);
const rustCrypto = await RustCrypto.initRustCrypto({
logger: this.logger,
http: this.http,
userId: userId,
deviceId: deviceId,
secretStorage: this.secretStorage,
cryptoCallbacks: this.cryptoCallbacks,
storePrefix: useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
storePassphrase: this.pickleKey,
legacyCryptoStore: this.cryptoStore,
legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY",
legacyMigrationProgressListener: (progress, total) => {
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
},
});
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
this.cryptoBackend = rustCrypto;
@@ -2344,6 +2349,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
CryptoEvent.KeyBackupSessionsRemaining,
CryptoEvent.KeyBackupFailed,
CryptoEvent.KeyBackupDecryptionKeyCached,
CryptoEvent.KeysChanged,
CryptoEvent.DevicesUpdated,
CryptoEvent.WillUpdateDevices,
]);
}
@@ -3235,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) {
@@ -3247,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);
@@ -3258,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;
}
@@ -3895,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);
@@ -3931,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 {
@@ -3958,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);
}
@@ -3967,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>;
@@ -4324,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);
@@ -4625,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) {
@@ -4728,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;
}
/**
@@ -4750,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 {
@@ -5680,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(
@@ -5688,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);
}
/**
@@ -5968,14 +6116,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const resOlder: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
null,
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
null,
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
);
@@ -5983,10 +6131,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Order events from most recent to oldest (reverse-chronological).
// We start with the last event, since that's the point at which we have known state.
// events_after is already backwards; events_before is forwards.
...resNewer.chunk.reverse().map(mapper),
...resNewer.chunk.reverse().filter(getRelationsThreadFilter(thread.id)).map(mapper),
event,
...resOlder.chunk.map(mapper),
...resOlder.chunk.filter(getRelationsThreadFilter(thread.id)).map(mapper),
];
for (const event of events) {
await timelineSet.thread?.processEvent(event);
}
@@ -6361,6 +6510,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
const token = res.end;
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
@@ -6388,7 +6538,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, null, null, {
dir,
limit: opts.limit,
from: token ?? undefined,
@@ -6396,7 +6546,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
})
.then(async (res) => {
const mapper = this.getEventMapper();
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
const matrixEvents = res.chunk
.filter(noUnsafeEventProps)
.filter(getRelationsThreadFilter(thread.id))
.map(mapper);
// Process latest events first
for (const event of matrixEvents.slice().reverse()) {
@@ -7444,16 +7597,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.serverVersionsPromise;
}
// We send an authenticated request as of MSC4026
this.serverVersionsPromise = this.http
.request<IServerVersions>(
Method.Get,
"/_matrix/client/versions",
undefined, // queryParams
undefined, // data
{
prefix: "",
},
)
.authedRequest<IServerVersions>(Method.Get, "/_matrix/client/versions", undefined, undefined, {
prefix: "",
})
.catch((e) => {
// Need to unset this if it fails, otherwise we'll never retry
this.serverVersionsPromise = undefined;
@@ -7591,7 +7739,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async relations(
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
relationType: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<{
@@ -7726,6 +7874,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setAccessToken(token: string): void {
this.http.opts.accessToken = token;
// The /versions response can vary for different users so clear the cache
this.serverVersionsPromise = undefined;
}
/**
@@ -8114,7 +8264,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public fetchRelations(
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
relationType: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<IRelationsResponse> {
@@ -9813,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) {
+50 -9
View File
@@ -18,7 +18,7 @@ import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { PassphraseInfo, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { VerificationRequest } from "./crypto-api/verification";
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
import { ISignatures } from "./@types/signed";
@@ -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. */
@@ -710,10 +746,15 @@ export interface CrossSigningKeyInfo {
}
/**
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} or {@link CreateSecretStorageOpts#createSecretStorageKey}.
*/
export interface GeneratedSecretStorageKey {
keyInfo?: AddSecretStorageKeyOpts;
keyInfo?: {
/** If the key was derived from a passphrase, information (algorithm, salt, etc) on that derivation. */
passphrase?: PassphraseInfo;
/** Optional human-readable name for the key, to be stored in account_data. */
name?: string;
};
/** The raw generated private key. */
privateKey: Uint8Array;
/** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */
+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. */
+4 -1
View File
@@ -38,7 +38,10 @@ interface ISecretRequestInternal {
export class SecretSharing {
private requests = new Map<string, ISecretRequestInternal>();
public constructor(private readonly baseApis: MatrixClient, private readonly cryptoCallbacks: ICryptoCallbacks) {}
public constructor(
private readonly baseApis: MatrixClient,
private readonly cryptoCallbacks: ICryptoCallbacks,
) {}
/**
* Request a secret from another device
+1 -5
View File
@@ -73,11 +73,7 @@ export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> im
/**
* Add a key for encrypting secrets.
*/
public addKey(
algorithm: string,
opts: AddSecretStorageKeyOpts = {},
keyId?: string,
): Promise<SecretStorageKeyObject> {
public addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject> {
return this.storageImpl.addKey(algorithm, opts, keyId);
}
+10 -2
View File
@@ -210,7 +210,11 @@ export abstract class DecryptionAlgorithm {
export class DecryptionError extends Error {
public readonly detailedString: string;
public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) {
public constructor(
public readonly code: string,
msg: string,
details?: Record<string, string | Error>,
) {
super(msg);
this.code = code;
this.name = "DecryptionError";
@@ -242,7 +246,11 @@ export class UnknownDeviceError extends Error {
* @param msg - message describing the problem
* @param devices - set of unknown devices per user we're warning about
*/
public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) {
public constructor(
msg: string,
public readonly devices: DeviceInfoMap,
public event?: MatrixEvent,
) {
super(msg);
this.name = "UnknownDeviceError";
this.devices = devices;
+4 -1
View File
@@ -164,7 +164,10 @@ class OutboundSessionInfo {
* @param sharedHistory - whether the session can be freely shared with
* other group members, according to the room history visibility settings
*/
public constructor(public readonly sessionId: string, public readonly sharedHistory = false) {
public constructor(
public readonly sessionId: string,
public readonly sharedHistory = false,
) {
this.creationTime = new Date().getTime();
}
+8 -2
View File
@@ -124,7 +124,10 @@ export class BackupManager {
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
private clientRunning = true;
public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
public constructor(
private readonly baseApis: MatrixClient,
public readonly getKey: GetKey,
) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
@@ -773,7 +776,10 @@ const UNSTABLE_MSC3270_NAME = new UnstableValue(
export class Aes256 implements BackupAlgorithm {
public static algorithmName = UNSTABLE_MSC3270_NAME.name;
public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {}
public constructor(
public readonly authData: IAes256AuthData,
private readonly key: Uint8Array,
) {}
public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
if (!authData) {
+75 -32
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 {
@@ -708,25 +719,30 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public async createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
const decryption = new global.Olm.PkDecryption();
try {
const keyInfo: Partial<IRecoveryKey["keyInfo"]> = {};
if (password) {
const derivation = await keyFromPassphrase(password);
keyInfo.passphrase = {
algorithm: "m.pbkdf2",
iterations: derivation.iterations,
salt: derivation.salt,
decryption.init_with_private_key(derivation.key);
const privateKey = decryption.get_private_key();
return {
keyInfo: {
passphrase: {
algorithm: "m.pbkdf2",
iterations: derivation.iterations,
salt: derivation.salt,
},
},
privateKey: privateKey,
encodedPrivateKey: encodeRecoveryKey(privateKey),
};
keyInfo.pubkey = decryption.init_with_private_key(derivation.key);
} else {
keyInfo.pubkey = decryption.generate_key();
decryption.generate_key();
const privateKey = decryption.get_private_key();
return {
privateKey: privateKey,
encodedPrivateKey: encodeRecoveryKey(privateKey),
};
}
const privateKey = decryption.get_private_key();
const encodedPrivateKey = encodeRecoveryKey(privateKey);
return {
keyInfo: keyInfo as IRecoveryKey["keyInfo"],
encodedPrivateKey,
privateKey,
};
} finally {
decryption?.free();
}
@@ -968,7 +984,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
// TODO this does not resolve with what it says it does
public async bootstrapSecretStorage({
createSecretStorageKey = async (): Promise<IRecoveryKey> => ({} as IRecoveryKey),
createSecretStorageKey = async (): Promise<IRecoveryKey> => ({}) as IRecoveryKey,
keyBackupInfo,
setupNewKeyBackup,
setupNewSecretStorage,
@@ -986,17 +1002,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
let newKeyId: string | null = null;
// create a new SSSS key and set it as default
const createSSSS = async (opts: AddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
if (privateKey) {
opts.key = privateKey;
}
const createSSSS = async (opts: AddSecretStorageKeyOpts): Promise<string> => {
const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts);
if (privateKey) {
// make the private key available to encrypt 4S secrets
builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey);
}
// make the private key available to encrypt 4S secrets
builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, opts.key);
await secretStorage.setDefaultKeyId(keyId);
return keyId;
@@ -1060,8 +1070,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// secrets using it, in theory. We could move them to the new key but a)
// that would mean we'd need to prompt for the old passphrase, and b)
// it's not clear that would be the right thing to do anyway.
const { keyInfo = {} as AddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
newKeyId = await createSSSS(keyInfo, privateKey);
const { keyInfo, privateKey } = await createSecretStorageKey();
newKeyId = await createSSSS({ passphrase: keyInfo?.passphrase, key: privateKey, name: keyInfo?.name });
} else if (!storageExists && keyBackupInfo) {
// we have an existing backup, but no SSSS
logger.log("Secret storage does not exist, using key backup key");
@@ -1071,7 +1081,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.());
// create a new SSSS key and use the backup key as the new SSSS key
const opts = {} as AddSecretStorageKeyOpts;
const opts = { key: backupKey } as AddSecretStorageKeyOpts;
if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) {
// FIXME: ???
@@ -1083,7 +1093,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
};
}
newKeyId = await createSSSS(opts, backupKey);
newKeyId = await createSSSS(opts);
// store the backup key in secret storage
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]);
@@ -2244,10 +2254,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const res = await this.baseApis.uploadKeysRequest(requestBody);
if (fallbackJson) {
this.fallbackCleanup = setTimeout(() => {
delete this.fallbackCleanup;
this.olmDevice.forgetOldFallbackKey();
}, 60 * 60 * 1000);
this.fallbackCleanup = setTimeout(
() => {
delete this.fallbackCleanup;
this.olmDevice.forgetOldFallbackKey();
},
60 * 60 * 1000,
);
}
await this.olmDevice.markKeysAsPublished();
@@ -3129,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
*
@@ -3171,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
@@ -4260,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;
}
+122
View File
@@ -46,8 +46,41 @@ export interface SecretStorePrivateKeys {
* Abstraction of things that can store data required for end-to-end encryption
*/
export interface CryptoStore {
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Unlike the rest of the methods in this interface, can be called before {@link CryptoStore#startup}.
*
* @internal
*/
containsData(): Promise<boolean>;
/**
* Initialise this crypto store.
*
* Typically, this involves provisioning storage, and migrating any existing data to the current version of the
* storage schema where appropriate.
*
* Must be called before any of the rest of the methods in this interface.
*/
startup(): Promise<CryptoStore>;
deleteAllData(): Promise<void>;
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
getMigrationState(): Promise<MigrationState>;
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
setMigrationState(migrationState: MigrationState): Promise<void>;
getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
@@ -99,6 +132,23 @@ export interface CryptoStore {
getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
/**
* Get a batch of end-to-end sessions from the database.
*
* @returns A batch of Olm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndSessionsBatch(): Promise<ISessionInfo[] | null>;
/**
* Delete a batch of end-to-end sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndSessionsBatch(sessions: { deviceKey?: string; sessionId?: string }[]): Promise<void>;
// Inbound Group Sessions
getEndToEndInboundGroupSession(
senderCurve25519Key: string,
@@ -126,6 +176,30 @@ 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<SessionExtended[] | null>;
/**
* Delete a batch of Megolm sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise<void>;
// Device Data
getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
@@ -149,12 +223,19 @@ export interface CryptoStore {
export type Mode = "readonly" | "readwrite";
/** Data on a Megolm session */
export interface ISession {
senderKey: string;
sessionId: string;
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;
sessionId?: string;
@@ -224,3 +305,44 @@ export interface ParkedSharedHistory {
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
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.
*
* Used by {@link CryptoStore#getMigrationState} and {@link CryptoStore#setMigrationState}.
*
* @internal
*/
export enum MigrationState {
/** No migration steps have yet been completed. */
NOT_STARTED,
/** We have migrated the account data, cross-signing keys, etc. */
INITIAL_DATA_MIGRATED,
/** INITIAL_DATA_MIGRATED, and in addition, we have migrated all the Olm sessions. */
OLM_SESSIONS_MIGRATED,
/** 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,
}
/**
* The size of batches to be returned by {@link CryptoStore#getEndToEndSessionsBatch} and
* {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}.
*/
export const SESSION_BATCH_SIZE = 50;
@@ -21,18 +21,23 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store";
const PROFILE_TRANSACTIONS = false;
@@ -40,6 +45,8 @@ const PROFILE_TRANSACTIONS = false;
* Implementation of a CryptoStore which is backed by an existing
* IndexedDB connection. Generally you want IndexedDBCryptoStore
* which connects to the database and defers to one of these.
*
* @internal
*/
export class Backend implements CryptoStore {
private nextTxnId = 0;
@@ -56,15 +63,49 @@ export class Backend implements CryptoStore {
};
}
public async containsData(): Promise<boolean> {
throw Error("Not implemented for Backend");
}
public async startup(): Promise<CryptoStore> {
// No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
// by passing us a ready IDBDatabase instance
return this;
}
public async deleteAllData(): Promise<void> {
throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead.");
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*/
public async getMigrationState(): Promise<MigrationState> {
let migrationState = MigrationState.NOT_STARTED;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
migrationState = getReq.result ?? MigrationState.NOT_STARTED;
};
});
return migrationState;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
objectStore.put(migrationState, ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
});
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
@@ -588,6 +629,62 @@ export class Backend implements CryptoStore {
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
result.push(cursor.value);
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
});
if (result.length === 0) {
// No sessions left.
return null;
}
return result;
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
for (const { deviceKey, sessionId } of sessions) {
const req = objectStore.delete([deviceKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
});
}
// Inbound group sessions
public getEndToEndInboundGroupSession(
@@ -712,6 +809,97 @@ 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 | 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);
}
};
},
);
if (result.length === 0) {
// No sessions left.
return null;
}
return result;
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
for (const { senderKey, sessionId } of sessions) {
const req = objectStore.delete([senderKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
});
}
public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
const objectStore = txn.objectStore("device_data");
const getReq = objectStore.get("-");
+144 -2
View File
@@ -25,12 +25,15 @@ import {
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -38,7 +41,7 @@ import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
/**
/*
* Internal module. indexeddb storage for e2e.
*/
@@ -61,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;
@@ -70,7 +119,21 @@ export class IndexedDBCryptoStore implements CryptoStore {
* @param indexedDB - global indexedDB instance
* @param dbName - name of db to connect to
*/
public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {}
public constructor(
private readonly indexedDB: IDBFactory,
private readonly dbName: string,
) {}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return IndexedDBCryptoStore.exists(this.indexedDB, this.dbName);
}
/**
* Ensure the database exists and is up-to-date, or fall back to
@@ -197,6 +260,28 @@ export class IndexedDBCryptoStore implements CryptoStore {
});
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
*/
public getMigrationState(): Promise<MigrationState> {
return this.backend!.getMigrationState();
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*
* @internal
*/
public setMigrationState(migrationState: MigrationState): Promise<void> {
return this.backend!.setMigrationState(migrationState);
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
@@ -468,6 +553,39 @@ 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.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
return this.backend!.getEndToEndSessionsBatch();
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
return this.backend!.deleteEndToEndSessionsBatch(sessions);
}
// Inbound group sessions
/**
@@ -544,6 +662,30 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
return this.backend!.getEndToEndInboundGroupSessionsBatch();
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
}
// End-to-end device tracking
/**
+179 -3
View File
@@ -16,7 +16,19 @@ limitations under the License.
import { logger } from "../../logger";
import { MemoryCryptoStore } from "./memory-crypto-store";
import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base";
import {
CryptoStore,
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
Mode,
SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
} from "./base";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { ICrossSigningKey } from "../../client";
@@ -32,6 +44,7 @@ import { safeSet } from "../../utils";
*/
const E2E_PREFIX = "crypto.";
const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration";
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
@@ -61,7 +74,7 @@ function keyEndToEndRoomsPrefix(roomId: string): string {
return KEY_ROOMS_PREFIX + roomId;
}
export class LocalStorageCryptoStore extends MemoryCryptoStore {
export class LocalStorageCryptoStore extends MemoryCryptoStore implements CryptoStore {
public static exists(store: Storage): boolean {
const length = store.length;
for (let i = 0; i < length; i++) {
@@ -76,12 +89,49 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
super();
}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return LocalStorageCryptoStore.exists(this.store);
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
*/
public async getMigrationState(): Promise<MigrationState> {
return getJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE) ?? MigrationState.NOT_STARTED;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*
* @internal
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
setJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE, migrationState);
}
// Olm Sessions
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);
}
@@ -192,6 +242,56 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const session of Object.values(this._getEndToEndSessions(deviceKey))) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this._getEndToEndSessions(deviceKey) || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
this.store.removeItem(keyEndToEndSessions(deviceKey));
} else {
setJsonItem(this.store, keyEndToEndSessions(deviceKey), deviceSessions);
}
}
}
// Inbound Group Sessions
public getEndToEndInboundGroupSession(
@@ -255,6 +355,82 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
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.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
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: key2.slice(0, 43),
sessionId: key2.slice(44),
sessionData: getJsonItem(this.store, key)!,
needsBackup: key2 in sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = keyEndToEndInboundGroupSession(senderKey, sessionId);
this.store.removeItem(k);
}
}
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(getJsonItem(this.store, KEY_DEVICE_DATA));
}
+146 -2
View File
@@ -15,18 +15,21 @@ limitations under the License.
*/
import { logger } from "../../logger";
import { safeSet, deepCompare, promiseTry } from "../../utils";
import { deepCompare, promiseTry, safeSet } from "../../utils";
import {
CryptoStore,
IDeviceData,
IProblem,
ISession,
SessionExtended,
ISessionInfo,
IWithheld,
MigrationState,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -39,6 +42,7 @@ import { InboundGroupSessionData } from "../OlmDevice";
*/
export class MemoryCryptoStore implements CryptoStore {
private migrationState: MigrationState = MigrationState.NOT_STARTED;
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
private account: string | null = null;
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
@@ -56,6 +60,18 @@ export class MemoryCryptoStore implements CryptoStore {
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
// If it contains anything, it should contain an account.
return this.account !== null;
}
/**
* Ensure the database exists and is up-to-date.
*
@@ -77,6 +93,28 @@ export class MemoryCryptoStore implements CryptoStore {
return Promise.resolve();
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
*/
public async getMigrationState(): Promise<MigrationState> {
return this.migrationState;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*
* @internal
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
this.migrationState = migrationState;
}
/**
* Look for an existing outgoing room key request, and if none is found,
* add a new one
@@ -298,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(
@@ -386,6 +428,51 @@ export class MemoryCryptoStore implements CryptoStore {
return ret;
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (const deviceSessions of Object.values(this.sessions)) {
for (const session of Object.values(deviceSessions)) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this.sessions[deviceKey] || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
delete this.sessions[deviceKey];
}
}
}
// Inbound Group Sessions
public getEndToEndInboundGroupSession(
@@ -445,6 +532,63 @@ 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.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
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;
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = senderKey + "/" + sessionId;
delete this.inboundGroupSessions[k];
}
}
// Device Data
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
+9 -6
View File
@@ -137,12 +137,15 @@ export class VerificationBase<
if (this.transactionTimeoutTimer !== null) {
clearTimeout(this.transactionTimeoutTimer);
}
this.transactionTimeoutTimer = setTimeout(() => {
if (!this._done && !this.cancelled) {
logger.info("Triggering verification timeout");
this.cancel(timeoutException);
}
}, 10 * 60 * 1000); // 10 minutes
this.transactionTimeoutTimer = setTimeout(
() => {
if (!this._done && !this.cancelled) {
logger.info("Triggering verification timeout");
this.cancel(timeoutException);
}
},
10 * 60 * 1000,
); // 10 minutes
}
private endTimer(): void {
@@ -39,7 +39,11 @@ export class InRoomChannel implements IVerificationChannel {
* @param roomId - id of the room where verification events should be posted in, should be a DM with the given user.
* @param userId - id of user that the verification request is directed at, should be present in the room.
*/
public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {}
public constructor(
private readonly client: MatrixClient,
public readonly roomId: string,
public userId?: string,
) {}
public get receiveStartFromOtherDevices(): boolean {
return true;
+8 -2
View File
@@ -21,7 +21,10 @@ export enum InvalidStoreState {
export class InvalidStoreError extends Error {
public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
public constructor(public readonly reason: InvalidStoreState, public readonly value: any) {
public constructor(
public readonly reason: InvalidStoreState,
public readonly value: any,
) {
const message =
`Store is invalid because ${reason}, ` +
`please stop the client, delete all data and start the client again`;
@@ -47,7 +50,10 @@ export class InvalidCryptoStoreError extends Error {
}
export class KeySignatureUploadError extends Error {
public constructor(message: string, public readonly value: any) {
public constructor(
message: string,
public readonly value: any,
) {
super(message);
}
}
+4 -1
View File
@@ -62,7 +62,10 @@ export interface IFilterComponent {
* 'Filters' are referred to as 'FilterCollections'.
*/
export class FilterComponent {
public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {}
public constructor(
private filterJson: IFilterComponent,
public readonly userId?: string | undefined | null,
) {}
/**
* Checks with the filter component matches the given event
+4 -1
View File
@@ -92,7 +92,10 @@ export class Filter {
* @param userId - The user ID for this filter.
* @param filterId - The filter ID if known.
*/
public constructor(public readonly userId: string | undefined | null, public filterId?: string) {}
public constructor(
public readonly userId: string | undefined | null,
public filterId?: string,
) {}
/**
* Get the ID of this filter on your homeserver (if known)
+4 -1
View File
@@ -30,7 +30,10 @@ interface IErrorJson extends Partial<IUsageLimit> {
* @param httpStatus - The HTTP response status code.
*/
export class HTTPError extends Error {
public constructor(msg: string, public readonly httpStatus?: number) {
public constructor(
msg: string,
public readonly httpStatus?: number,
) {
super(msg);
}
}
+2 -2
View File
@@ -33,8 +33,8 @@ interface TypedResponse<T> extends Response {
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
? T
: O extends { onlyData: true }
? T
: TypedResponse<T>;
? T
: TypedResponse<T>;
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
+1 -1
View File
@@ -40,7 +40,7 @@ export enum MediaPrefix {
/**
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
*/
V1 = "/_matrix/media/v3",
V1 = "/_matrix/media/v1",
/**
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
*/

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