Compare commits

...

107 Commits

Author SHA1 Message Date
RiotRobot a86d4ceb49 v32.4.0 2024-05-22 12:04:07 +00:00
RiotRobot 38898a60c7 v32.3.0 2024-05-21 12:24:52 +00:00
RiotRobot 3a4b02d8e6 v32.3.0-rc.0 2024-05-15 09:04:25 +00:00
Kegan Dougal d421e7f829 Run complement-crypto in CI (#4197)
* Maybe run complement-crypto

* Use existing checkout

* Test that things fail if crypto breaks

* Fix test; run only on merge queue

* Prettier

* Maybe get it working in a merge queue
2024-05-13 10:26:27 +00:00
Michael Telatynski 9fd051af33 Update downstream-end-to-end-tests.yml 2024-05-13 11:20:47 +01:00
Michael Telatynski b78a1ad889 Hotfix types export point
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-10 13:26:07 +01:00
Michael Telatynski a25cdcecaa Fix state_events.ts types (#4196)
* Fix state_events.ts types

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

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-10 10:55:44 +00:00
RiotRobot 2a716bd076 Resetting package fields for development 2024-05-07 12:16:24 +00:00
RiotRobot ef1db8d664 Merge branch 'master' into develop 2024-05-07 12:16:23 +00:00
RiotRobot 22865fd834 v32.2.0 2024-05-07 12:15:48 +00:00
Michael Telatynski c4fe564855 Simplify OIDC types & export decodeIdToken (#4193)
* Fix types

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

* Export `decodeIdToken`

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-07 10:21:57 +00:00
Michael Telatynski 9ecb1a0381 Fix sendEventHttpRequest for m.room.redaction events without redacts (#4192)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-07 08:50:37 +00:00
Travis Ralston ef9490c7b1 Add missing (and common-ish) HTTP method verbs to types (#4188) 2024-05-02 23:09:15 +00:00
Travis Ralston 402adfbe8a Add helpers for authenticated media, and associated documentation (#4185)
* Add helpers for authenticated media, and associated documentation

* Appease the linter
2024-05-02 21:11:09 +00:00
renovate[bot] 41e8c2af34 Update dependency typedoc-plugin-coverage to v3.1.1 (#4186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-01 10:23:17 +00:00
renovate[bot] 4843b40296 Update dependency typedoc-plugin-mdn-links to v3.1.22 (#4182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:18:45 +00:00
renovate[bot] bc2c870152 Update babel monorepo to v7.24.5 (#4181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:17:37 +00:00
renovate[bot] 7c7b2817d3 Update all non-major dependencies (#4184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:16:31 +00:00
renovate[bot] 9f78202ecd Update typescript-eslint monorepo to v7.7.1 (#4183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-30 14:40:57 +01:00
RiotRobot abc9911e95 v32.2.0-rc.0 2024-04-30 12:02:20 +00:00
David Baker efdae0d66f Remove spammy RTCSession log line (#4180)
This can get quite spammy in rooms with legacy calls and isn't really
neccesary: remove it to cut down on the log spam.
2024-04-30 10:43:20 +00:00
David Baker 2bf554761c Add some comments to the release drafter workflows (#4140)
* Add some comments to the release drafter workflows

* Rename component workflow so they have different names

* Fix comment

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-30 09:38:07 +00:00
Michael Telatynski c2687643b5 Update downstream-end-to-end-tests.yml 2024-04-30 10:30:19 +01:00
Michael Telatynski a33758eda6 Update downstream-end-to-end-tests.yml 2024-04-30 10:28:44 +01:00
Michael Telatynski 8faed02cc5 Update sonarcloud.yml 2024-04-29 17:22:32 +01:00
Michael Telatynski 3ae0dab47a Update sonarcloud.yml 2024-04-29 16:59:55 +01:00
Michael Telatynski 95394e4cbe Update sonarcloud.yml 2024-04-29 16:52:54 +01:00
Michael Telatynski 8c9bbc01fc Update downstream-end-to-end-tests.yml 2024-04-29 16:20:24 +01:00
Michael Telatynski eb888791a3 Pass all args to Sonar via sonar-project.properties file (#4179)
* Pass all args to Sonar via sonar-project.properties file

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

* Update Sonarcloud action to v3.1

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

* Apply suggestions from code review

Co-authored-by: davidegirardi <16451191+davidegirardi@users.noreply.github.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: davidegirardi <16451191+davidegirardi@users.noreply.github.com>
2024-04-29 15:12:00 +00:00
Michael Telatynski 6c0b2f55e1 Delete .github/workflows/downstream-artifacts.yml 2024-04-29 10:53:12 +01:00
Michael Telatynski c9a5eaece3 Simplify Playwright CI (#4178) 2024-04-29 08:49:00 +01:00
Hubert Chathi 64505de36b Use a different error code for UTDs when user was not in the room (#4172)
* use a different error code for UTDs when user was not in the room

* if user is invited, treat it as unexpected UTD
2024-04-26 13:38:10 +00:00
Valere 65d858f9a3 Fix rust migration when ssss secret not encrypted (#4168) 2024-04-26 12:59:17 +00:00
RiotRobot 1da5e8f56a Resetting package fields for development 2024-04-23 12:34:26 +00:00
RiotRobot 5efd4c2915 Merge branch 'master' into develop 2024-04-23 12:34:25 +00:00
RiotRobot bc03950f8a v32.1.0 2024-04-23 12:33:56 +00:00
Travis Ralston c09da9a23f Modernize window.crypto access constants (#4169)
* Force service worker-safe crypto when operating under a service worker

* Fix tests/unsafe execution

* Further fix tests?

* Docs would probably be good

* Define a type guard function

https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards

* Use `@types` repo

* Maybe don't modify tsconfig, I guess

* Revert "Use `@types` repo"

This reverts commit db46bcf1db4b94fbc7e0c97a20d5d800fcb2768b.

* Use a different type for Window

* Simplify the crypto accessors
2024-04-22 19:44:01 +00:00
Andrew Ferrazzutti e874468ba3 Improve compliance with MSC3266 (#4155)
* Fix fields of MSC 3266 summary object

Also remove redundant room_type field which is inherited from elsewhere

* Export the MSC 3266 summary type

* Use proper endpoint for MSC 3266 summary lookup

Use the endpoint recommended by the MSC

* Rename newly-exported symbol to not start with I

* Use "export type"

* Lint

* Fix type of "encryption" field

* Add TSDoc documentation

* Add basic integration test for getRoomSummary

* Lint

* Use fallback endpoint for MSC3266

* Improve test coverage

* Lint

* Refactor async catch to satisfy linter

* Increase test coverage
2024-04-22 17:39:10 +00:00
Johannes Marbach 6fedda91f9 Use encoded URI components when storing sessions in memory crypto store (#4170)
* Use encoded URI components when storing sessions in memory crypto store

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Add URI en-/decoding to missing methods

* Extract convenience functions

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2024-04-22 08:12:54 +00:00
Valere d22a39f5d7 Element R: fix isCrossSigningReady not checking identity trust (#4156)
* Fix inconsistency between rust and legacy

* Add tests

* Review: better comment

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>

* Simplify test data and some comments

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-04-17 13:36:52 +00:00
Timo 4fc6ba884e add comment to make clear that RoomStateEvent.Events does not update related objects in the js-sdk (#4152)
Signed-off-by: Timo K <toger5@hotmail.de>
2024-04-17 10:28:40 +00:00
Richard van der Hoff c30e498013 Crypto: use a new error code for UTDs from device-relative historical events (#4139)
* Add `PerSessionKeyBackupDownloader.isKeyBackupDownloadConfigured()`

* Add new `RustBackupManager.getServerBackupInfo`

... and a convenience method in PerSessionKeyBackupDownloader to access it.

* Crypto.spec: move `useRealTimers` to global `afterEach`

... so that we don't need to remember to do it everywhere.

* Use fake timers for UTD error code tests

This doesn't have any effect on the tests, but *does* stop jest from hanging
when you run the tests in in-band mode. It shouldn't *really* be needed, but
using fake timers gives more reproducible tests, and I don't have the
time/patience to debug why it is needed.

* Use new error codes for UTDs from historical events
2024-04-17 10:26:41 +00:00
renovate[bot] 8240bf0ae7 Update shogo82148/actions-upload-release-asset digest to 8f032ef (#4157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 15:59:04 +00:00
renovate[bot] 2321c44687 Update typedoc (#4162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 15:22:17 +00:00
renovate[bot] 0137e9d5a8 Update dependency eslint-plugin-unicorn to v52 (#4166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 14:29:48 +00:00
renovate[bot] 28bbc51752 Update dependency @types/node to v18.19.31 (#4160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 14:06:49 +00:00
renovate[bot] 0db3ac9b43 Update dependency eslint-plugin-jest to v28 (#4165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:56:50 +00:00
renovate[bot] 53039b78ee Update all non-major dependencies (#4158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:25:06 +00:00
renovate[bot] 2a06d19431 Update babel monorepo to v7.24.4 (#4159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:24:46 +00:00
renovate[bot] a747eef04c Update dependency typescript to v5.4.5 (#4161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:24:27 +00:00
renovate[bot] 583823c2ef Update dependency knip to v5 (#4167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:21:14 +00:00
renovate[bot] 26d13c15c3 Update typescript-eslint monorepo to v7.6.0 (#4163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:20:45 +00:00
RiotRobot c850ca3179 v32.1.0-rc.0 2024-04-16 12:16:13 +00:00
Valere 8438533532 Validate backup private key before migrating it (#4114)
* Migrate own identity trust to rust crypto

* Fix gendoc not happy if msk of IDownloadKeyResult has a signature

* add missing mock

* code review

* Code review

* Review gh suggestion

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

* Review gh suggestion

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

* Review gh suggestion

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

* Review gh suggestion

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

* review move function down in file

* Review gh suggestion

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

* Review gh suggestion

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

* Review: Cleaning tests, renaming

* Review: better comment

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

* Comment paragraphs

* retry until initial  key query is successfull

* Validate backup private key before migrating it

* post merge fix

* Fix test, missing mock

* Use crypto wasm instead of lib olm to check backup key

* typo

* code review

* quick lint

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-04-15 07:17:14 +00:00
David Baker 475f82c5ce Update git-get-release action (#4148)
which appears to be breaking renovate somehow, and could probably use
and update anyway.
2024-04-11 09:36:07 +00:00
Hubert Chathi 936e7c3072 Add support for device dehydration v2 (Element R) (#4062)
* initial implementation of device dehydration

* add dehydrated flag for devices

* add missing dehydration.ts file, add test, add function to schedule dehydration

* add more dehydration utility functions

* stop scheduled dehydration when crypto stops

* bump matrix-crypto-sdk-wasm version, and fix tests

* adding dehydratedDevices member to mock OlmDevice isn't necessary any more

* fix yarn lock file

* more tests

* fix test

* more tests

* fix typo

* fix logic for checking if dehydration supported

* make changes from review

* add missing file

* move setup into another function

* apply changes from review

* implement simpler API

* fix type and move the code to the right spot

* apply suggestions from review

* make sure that cross-signing and secret storage are set up
2024-04-11 04:01:47 +00:00
RiotRobot 82ed7bd86a Resetting package fields for development 2024-04-09 10:07:09 +00:00
RiotRobot cb67eae858 Merge branch 'master' into develop 2024-04-09 10:07:07 +00:00
RiotRobot e4937e6222 v32.0.0 2024-04-09 10:06:41 +00:00
Michael Telatynski 5cdd524da7 OIDC improvements in prep of OIDC-QR reciprocation (#4149)
* Add `device_authorization_endpoint` field to OIDC issuer well-known metadata

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

* Allow `validateIdToken` to skip handling nonce when none is present

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

* Tweak registerOidcClient to check OIDC grant_types_supported before registration

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-08 14:07:00 +00:00
Michael Telatynski 0ff0093380 Add knip unused code & dependency analyser (#4013) 2024-04-08 11:04:40 +01:00
Valere b352405c89 ElementR| Retry query backup until it works during migration to avoid spurious correption error popup (#4113)
* retry query backup until it works during migration

* Add log line when fails to get backup during migration
2024-04-08 07:11:06 +00:00
renovate[bot] 0d73d0c6c7 Update typescript-eslint monorepo to v7.4.0 (#4146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 09:49:35 +00:00
renovate[bot] d2f76d4956 Update dependency typescript to v5.4.3 (#4145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 09:23:09 +00:00
renovate[bot] c680dd7eb2 Update dependency @types/node to v18.19.28 (#4144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 08:37:32 +00:00
renovate[bot] e24bb0f50c Update babel monorepo to v7.24.3 (#4143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 08:36:51 +00:00
renovate[bot] 1ed3b13f0d Update all non-major dependencies (#4142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-02 17:58:13 +00:00
renovate[bot] 4f628bf64c Update mheap/github-action-required-labels digest to 132879b (#4141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-02 17:08:08 +00:00
RiotRobot 7d5c003716 v32.0.0-rc.0 2024-04-02 16:15:04 +00:00
Richard van der Hoff dbab185f9d Refactoring and simplification in decryption error handling (#4138)
* Clean up decryption failure integ tests

* Fix the names
* Stop waiting as soon as the event is decrypted, even if code is wrong (so
  tests fail rather than time out if the code is wrong)

* Bump timeouts on some tests

These tend to fail due to slow init of wasm artifacts

* Factor out `onDecryptionKeyMissingError` call

* Factor out `onMegolmDecryptionError`
2024-04-02 13:39:49 +00:00
Richard van der Hoff cfcd191cbf Update matrix-rust-sdk-crypto-wasm to 4.9.0 (#4137) 2024-04-02 13:09:59 +00:00
RiotRobot 514633c5fa Merge branch 'master' into develop 2024-03-28 16:45:10 +00:00
RiotRobot 5bffb7df4f v31.6.1 2024-03-28 16:44:30 +00:00
David Baker 9e1897dcd0 Merge pull request #4136 from matrix-org/backport-4135-to-staging
Fix merging of default push rules (backport)
2024-03-28 16:35:36 +00:00
David Baker 5f3ddc37a1 Merge pull request #4135 from matrix-org/t3chguy/fix/27173
Fix merging of default push rules

(cherry picked from commit 78a225795b)
2024-03-28 16:28:33 +00:00
David Baker 78a225795b Merge pull request #4135 from matrix-org/t3chguy/fix/27173
Fix merging of default push rules
2024-03-28 16:23:58 +00:00
Michael Telatynski 467b49a0dc Add test
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-28 15:52:12 +00:00
Michael Telatynski 06e083874a Iterate
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-28 14:59:52 +00:00
Michael Telatynski 0f25429849 Iterate
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-28 13:41:31 +00:00
Michael Telatynski 32ddf2813d Iterate
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-28 12:55:44 +00:00
Michael Telatynski 1ed082f3d4 Fix merging of default push rules
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-28 11:58:52 +00:00
RiotRobot 706002cdcb Resetting package fields for development 2024-03-26 16:26:09 +00:00
RiotRobot 731de1108c Merge branch 'master' into develop 2024-03-26 16:26:07 +00:00
RiotRobot 2da6c0c605 v31.6.0 2024-03-26 16:25:15 +00:00
Richard van der Hoff 9f1d0c3896 Add new decryptExistingEvent test helper (#4133)
* grammar fix

* IEncryptionResult -> EncryptionResult

These are the same thing; the former is the old name.

* Support setting event IDs

* Helper for decrypting existing decryption failures
2024-03-25 14:10:58 +00:00
Gabri 0b290fffa1 Improve types for IPowerLevelsContent and hasSufficientPowerLevelFor (#4128)
Signed-off-by: Gabriele Messina <56839513+galash13@users.noreply.github.com>
2024-03-25 13:58:38 +00:00
Michael Telatynski 97844f0e47 Improve types for sendEvent (#4108) 2024-03-25 12:48:49 +00:00
Michael Telatynski 85a55c79cd Remove various deprecated methods & re-exports (#4125) 2024-03-25 12:21:11 +00:00
Johannes Marbach 63d4195453 Use RoomEvent.MyMembership in auto-join example (#4130)
This seems easier / more efficient than listening to all membership events and then filtering by user ID.
2024-03-25 09:10:42 +00:00
Richard van der Hoff d5a35f8a99 Add new enum for verification methods. (#4129)
* Define constants for the verification methods.

* Remove some confusing references to the *old* `VerificationMethod`
2024-03-22 17:17:31 +00:00
Richard van der Hoff d1259b241c Clean up code for handling decryption failures (#4126)
Various improvements, including:

* Defining an enum for decryption failure reasons
* Exposing the reason code as a property on Event
2024-03-22 17:15:27 +00:00
David Langley a573727662 Remove the logic that throws when the lazy loading options has changed. (#4124)
* remove InvalidStoreState and the logic that checks for the change in the lazyLoading client option

* lint
2024-03-22 16:36:23 +00:00
Richard van der Hoff dce8acbf17 Add some test utils in a new entrypoint (#4127)
* Clean up README a little

This just removes some of the most egregious lies and outdated stuff. There's a
*lot* more that can be done here.

* Add some test utils in a new entrypoint

* Fix comment

* Update src/testing.ts
2024-03-22 14:10:55 +00:00
David Baker 4ba1341f8f Fix highlights from threads disappearing on new messages (#4106)
* Fix highlights from threads disappearing on new messages

This changes interface of Room, so this is a BREAKING CHANGE.

Correctly mirrors the logic we use for room notifications for thread
notifications, ie. set only the total notifications count from the
server if it's zero.

I'm not delighted with this since it ends up with function on room
whose contract is to do something frankly, deeply weird and
unintuitive. However, this is the hack we use for room notifications
and it, empirically, works well enough. To do better, we'd need much
more complex logic to overlay notification counts for decrypted messages.

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

* Add tests for the special notification behaviour in syncing

* Correctly copy the room logic for reseting notifications

We were always ignoring the highlight count, even for encrypted rooms,
which was broken because we don't do the local calculation for unencrypted
rooms.
2024-03-21 16:29:00 +00:00
David Baker e517d009bf Extend logic for local notification processing to threads (#4111)
* Move code for processing our own receipts to Room

This is some code to process our own receipts and recalculate our
notification counts.

There was no reason for this to be in client. Room is still rather
large, but at least it makes somewhat more sense there.

Moving as a refactor before I start work on it.

* Add test for the client-side e2e notifications code

* Extend logic for local notification processing to threads

There's collection of logic for for processing receipts and recomputing
notifications for encrypted rooms, but we didn't do the same for threads.
As a reasult, when I tried pulling some of the logic over in
https://github.com/matrix-org/matrix-js-sdk/pull/4106
clearing notifications on threads just broke.

This extends the logic of reprocessing local notifications when a receipt
arrives to threads.

Based on https://github.com/matrix-org/matrix-js-sdk/pull/4109

* simplify object literal

* Add tests & null guard

* Remove unused imports

* Add another skipped test

* Unused import

* enable tests

* Fix thread support nightmare

* Try this way

* Unused import

* Comment the bear trap

* expand comment
2024-03-21 12:22:19 +00:00
Ajay Bura dc2d03dea5 fix public rooms post request search params and body (#4110) 2024-03-21 10:29:51 +00:00
David Baker d5bb9e7600 Move code for processing our own receipts to Room (#4109)
* Move code for processing our own receipts to Room

This is some code to process our own receipts and recalculate our
notification counts.

There was no reason for this to be in client. Room is still rather
large, but at least it makes somewhat more sense there.

Moving as a refactor before I start work on it.

* Add test for the client-side e2e notifications code

* simplify object literal
2024-03-20 15:20:47 +00:00
Michael Telatynski d908036f50 Improve types for sendStateEvent (#4105) 2024-03-20 14:27:27 +00:00
David Baker afc3c6213b Fix bugs with the first reply to a thread (#4104)
* WIP fix for bugs first-thread-reply bugs

* Add re-emitter before we start adding events, as per comment

* Add test for notification bug

* Test for the bug that caused the dot to be the wrong colour

* Add comment

* elaborate

* Fix outdated comment

* Also fix this comment

* Fix another comment

* Fix typo

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

* Clarify comment

* More comment

* so much comment

also reformat (the bit that's actually added is s/it/this.addEvents/)

* The comments

* Maybe make comment clearer.

* Add comment about potential race

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-03-20 11:14:25 +00:00
Michael Telatynski 7884c22e41 Fix permissions for deploying docs to github pages (#4122)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-19 17:57:33 +00:00
renovate[bot] 887d8a7663 Update dependency typescript to v5.4.2 (#4123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 17:25:31 +00:00
renovate[bot] 2c68ee2254 Update babel monorepo to v7.24.1 (#4119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:33:19 +00:00
renovate[bot] d445823d0b Update typescript-eslint monorepo to v7.2.0 (#4121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:25:50 +00:00
renovate[bot] abe4630687 Update typedoc (#4118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:25:07 +00:00
renovate[bot] 8664b66238 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v4.7.0 (#4120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:24:49 +00:00
renovate[bot] 596826ab4d Update all non-major dependencies (#4116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:22:03 +00:00
renovate[bot] c8ec5421c7 Update dependency @types/node to v18.19.24 (#4117)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 16:21:55 +00:00
97 changed files with 5454 additions and 1948 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
@@ -1,22 +0,0 @@
name: Build downstream artifacts
on:
merge_group:
types: [checks_requested]
pull_request: {}
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
# to develop or master.
#
#push:
# branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.93.0
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
@@ -3,10 +3,16 @@
name: matrix-react-sdk End to End Tests
on:
workflow_run:
workflows: ["Build downstream artifacts"]
types:
- completed
merge_group:
types: [checks_requested]
pull_request: {}
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
# to develop or master.
#
#push:
# branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
@@ -15,44 +21,14 @@ concurrency:
jobs:
playwright:
name: Playwright
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
if: github.event.workflow_run.event == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
deployments: write
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
# We want to make the Playwright tests a required check for the merge queue.
#
# 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 Playwright tests, we need to add a
# passing status check manually.
mark_skipped:
if: github.event.workflow_run.event != 'merge_group'
permissions:
statuses: write
runs-on: ubuntu-latest
steps:
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: "${{ secrets.GITHUB_TOKEN }}"
state: success
description: Playwright skipped
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
context: "${{ github.workflow }} / end-to-end-tests"
sha: "${{ github.event.workflow_run.head_sha }}"
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
skip: ${{ github.event_name != 'merge_group' }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-latest
steps:
- uses: mheap/github-action-required-labels@80a96a4863886addcbc9f681b5b295ba7f5424e1 # v5
- uses: mheap/github-action-required-labels@132879b972cb7f2ac593006455875098e73cc7f2 # v5
if: github.event_name != 'merge_group'
with:
labels: |
@@ -1,4 +1,5 @@
name: Release Drafter
# Workflow used by other workflows to generate draft releases.
name: Release Drafter Reusable
on:
workflow_call:
inputs:
+4
View File
@@ -1,3 +1,7 @@
# Generates the draft release for the js-sdk
# Normally triggered whenever anything is merged to the staging branch, but
# also has a workflow dispatch trigger in case it needs running manually due
# to failures / workflow updates etc.
name: Release Drafter
on:
push:
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
- name: Get draft release
id: draft-release
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@4b07b26a2f6e0a51846e1870223e545bae91c552 # v3.0.1
uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c # v3.1.1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
+5
View File
@@ -61,6 +61,11 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: docs
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Deploy to GitHub Pages
id: deployment
+5 -13
View File
@@ -12,10 +12,6 @@ on:
type: boolean
required: false
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
extra_args:
type: string
required: false
description: "Extra args to pass to SonarCloud"
jobs:
sonarqube:
runs-on: ubuntu-latest
@@ -60,14 +56,14 @@ jobs:
- 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
coverage=$(find coverage -type f -name '*lcov.info' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
echo "sonar.javascript.lcov.reportPaths=$coverage" >> sonar-project.properties
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
echo "sonar.testExecutionReportPaths=$reports" >> sonar-project.properties
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.7
uses: matrix-org/sonarcloud-workflow-action@v3.2
# 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:
@@ -78,10 +74,6 @@ jobs:
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
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()
+16
View File
@@ -90,3 +90,19 @@ jobs:
path: _docs
# We'll only use this in a workflow_run, then we're done with it
retention-days: 1
analyse_dead_code:
name: "Analyse Dead Code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"
+18
View File
@@ -71,6 +71,24 @@ jobs:
disable_coverage: true
matrix-js-sdk-sha: ${{ github.sha }}
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
with:
use_js_sdk: "."
# we need this so the job is reported properly when run in a merge queue
downstream-complement-crypto:
name: Downstream Complement Crypto tests
runs-on: ubuntu-latest
if: always()
needs:
- complement-crypto
steps:
- if: needs.complement-crypto.result != 'skipped' && needs.complement-crypto.result != 'success'
run: exit 1
# Hook for branch protection to skip downstream testing outside of merge queues
# and skip sonarcloud coverage within merge queues
downstream:
+96
View File
@@ -1,3 +1,99 @@
Changes in [32.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.4.0) (2024-05-22)
==================================================================================================
* No changes
Changes in [32.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.3.0) (2024-05-21)
==================================================================================================
## ✨ Features
* Simplify OIDC types \& export `decodeIdToken` ([#4193](https://github.com/matrix-org/matrix-js-sdk/pull/4193)). Contributed by @t3chguy.
* Add helpers for authenticated media, and associated documentation ([#4185](https://github.com/matrix-org/matrix-js-sdk/pull/4185)). Contributed by @turt2live.
## 🐛 Bug Fixes
* Fix state\_events.ts types ([#4196](https://github.com/matrix-org/matrix-js-sdk/pull/4196)). Contributed by @t3chguy.
* Fix sendEventHttpRequest for `m.room.redaction` events without `redacts` ([#4192](https://github.com/matrix-org/matrix-js-sdk/pull/4192)). Contributed by @t3chguy.
Changes in [32.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.2.0) (2024-05-07)
==================================================================================================
## ✨ Features
* Use a different error code for UTDs when user was not in the room ([#4172](https://github.com/matrix-org/matrix-js-sdk/pull/4172)). Contributed by @uhoreg.
* Modernize window.crypto access constants ([#4169](https://github.com/matrix-org/matrix-js-sdk/pull/4169)). Contributed by @turt2live.
* Improve compliance with MSC3266 ([#4155](https://github.com/matrix-org/matrix-js-sdk/pull/4155)). Contributed by @AndrewFerr.
* Add comment to make clear that RoomStateEvent.Events does not update related objects in the js-sdk ([#4152](https://github.com/matrix-org/matrix-js-sdk/pull/4152)). Contributed by @toger5.
* Crypto: use a new error code for UTDs from device-relative historical events ([#4139](https://github.com/matrix-org/matrix-js-sdk/pull/4139)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Element-R: Fix rust migration when ssss secret are stored not encryted in cache (old legacy behavior) ([#4168](https://github.com/matrix-org/matrix-js-sdk/pull/4168)). Contributed by @BillCarsonFr.
Changes in [32.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.1.0) (2024-04-23)
==================================================================================================
## ✨ Features
* Add support for device dehydration v2 (Element R) ([#4062](https://github.com/matrix-org/matrix-js-sdk/pull/4062)). Contributed by @uhoreg.
* OIDC improvements in prep of OIDC-QR reciprocation ([#4149](https://github.com/matrix-org/matrix-js-sdk/pull/4149)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Validate backup private key before migrating it ([#4114](https://github.com/matrix-org/matrix-js-sdk/pull/4114)). Contributed by @BillCarsonFr.
* ElementR| Retry query backup until it works during migration to avoid spurious correption error popup ([#4113](https://github.com/matrix-org/matrix-js-sdk/pull/4113)). Contributed by @BillCarsonFr.
Changes in [32.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.0.0) (2024-04-09)
==================================================================================================
## 🚨 BREAKING CHANGES
* Remove various deprecated methods \& re-exports ([#4125](https://github.com/matrix-org/matrix-js-sdk/pull/4125)). Contributed by @t3chguy.
* Remove the logic that throws when the lazy loading options has changed. ([#4124](https://github.com/matrix-org/matrix-js-sdk/pull/4124)). Contributed by @langleyd.
* Fix highlights from threads disappearing on new messages ([#4106](https://github.com/matrix-org/matrix-js-sdk/pull/4106)). Contributed by @dbkr.
## ✨ Features
* Add new `decryptExistingEvent` test helper ([#4133](https://github.com/matrix-org/matrix-js-sdk/pull/4133)). Contributed by @richvdh.
* Improve types for `sendEvent` ([#4108](https://github.com/matrix-org/matrix-js-sdk/pull/4108)). Contributed by @t3chguy.
* Remove various deprecated methods \& re-exports ([#4125](https://github.com/matrix-org/matrix-js-sdk/pull/4125)). Contributed by @t3chguy.
* Add new enum for verification methods. ([#4129](https://github.com/matrix-org/matrix-js-sdk/pull/4129)). Contributed by @richvdh.
* Add some test utils in a new entrypoint ([#4127](https://github.com/matrix-org/matrix-js-sdk/pull/4127)). Contributed by @richvdh.
* Improve types for `sendStateEvent` ([#4105](https://github.com/matrix-org/matrix-js-sdk/pull/4105)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Improve types for `IPowerLevelsContent` and `hasSufficientPowerLevelFor` ([#4128](https://github.com/matrix-org/matrix-js-sdk/pull/4128)). Contributed by @galash13.
* Remove the logic that throws when the lazy loading options has changed. ([#4124](https://github.com/matrix-org/matrix-js-sdk/pull/4124)). Contributed by @langleyd.
* Fix highlights from threads disappearing on new messages ([#4106](https://github.com/matrix-org/matrix-js-sdk/pull/4106)). Contributed by @dbkr.
* Extend logic for local notification processing to threads ([#4111](https://github.com/matrix-org/matrix-js-sdk/pull/4111)). Contributed by @dbkr.
* Fix public rooms post request search params and body ([#4110](https://github.com/matrix-org/matrix-js-sdk/pull/4110)). Contributed by @ajbura.
* Fix bugs with the first reply to a thread ([#4104](https://github.com/matrix-org/matrix-js-sdk/pull/4104)). Contributed by @dbkr.
Changes in [31.6.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.6.1) (2024-03-28)
==================================================================================================
## 🐛 Bug Fixes
* Fix merging of default push rules ([#4136](https://github.com/matrix-org/matrix-js-sdk/pull/4136)).
Changes in [31.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.6.0) (2024-03-26)
==================================================================================================
## ✨ Features
* Introduce Membership TS type (take 2) ([#4107](https://github.com/matrix-org/matrix-js-sdk/pull/4107)). Contributed by @andybalaam.
* fix automatic DM avatar with functional members ([#4017](https://github.com/matrix-org/matrix-js-sdk/pull/4017)). Contributed by @HarHarLinks.
* Export types describing all specced media event formats ([#4092](https://github.com/matrix-org/matrix-js-sdk/pull/4092)). Contributed by @t3chguy.
* Add `.m.rule.is_room_mention` push rule to DEFAULT\_OVERRIDE\_RULES ([#4100](https://github.com/matrix-org/matrix-js-sdk/pull/4100)). Contributed by @t3chguy.
* Make sending ContentLoaded optional for a widgetClient ([#4086](https://github.com/matrix-org/matrix-js-sdk/pull/4086)). Contributed by @toger5.
## 🐛 Bug Fixes
* Migrate own identity local trust to rust crypto ([#4090](https://github.com/matrix-org/matrix-js-sdk/pull/4090)). Contributed by @BillCarsonFr.
* Fix race condition with sliding sync extensions ([#4089](https://github.com/matrix-org/matrix-js-sdk/pull/4089)). Contributed by @zzorba.
Changes in [31.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.5.0) (2024-03-12)
==================================================================================================
## ✨ Features
+58 -59
View File
@@ -21,15 +21,9 @@ endpoints from before Matrix 1.1, for example.
# Quickstart
## In a browser
### Note, the browserify build has been removed. Please use a bundler like webpack or vite instead.
## In Node.js
Ensure you have the latest LTS version of Node.js installed.
This library relies on `fetch` which is available in Node from v18.0.0 - it should work fine also with polyfills.
If you wish to use a ponyfill or adapter of some sort then pass it as `fetchFn` to the MatrixClient constructor options.
> [!IMPORTANT]
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
@@ -47,8 +41,6 @@ client.publicRooms(function (err, data) {
See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node) for a more complex example.
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
To start the client:
```javascript
@@ -101,12 +93,40 @@ Object.keys(client.store.rooms).forEach((roomId) => {
});
```
## Authenticated media
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) will require clients, like
yours, to include an `Authorization` header when `/download`ing or `/thumbnail`ing media. For NodeJS environments this
may be as easy as the following code snippet, though web browsers may need to use [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
to append the header when using the endpoints in `<img />` elements and similar.
```javascript
const downloadUrl = client.mxcUrlToHttp(
/*mxcUrl=*/ "mxc://example.org/abc123", // the MXC URI to download/thumbnail, typically from an event or profile
/*width=*/ undefined, // part of the thumbnail API. Use as required.
/*height=*/ undefined, // part of the thumbnail API. Use as required.
/*resizeMethod=*/ undefined, // part of the thumbnail API. Use as required.
/*allowDirectLinks=*/ false, // should generally be left `false`.
/*allowRedirects=*/ true, // implied supported with authentication
/*useAuthentication=*/ true, // the flag we're after in this example
);
const img = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${client.getAccessToken()}`,
},
});
// Do something with `img`.
```
> [!WARNING]
> In future the js-sdk will _only_ return authentication-required URLs, mandating population of the `Authorization` header.
## What does this SDK do?
This SDK provides a full object model around the Matrix Client-Server API and emits
events for incoming data and state changes. Aside from wrapping the HTTP API, it:
- Handles syncing (via `/initialSync` and `/events`)
- Handles syncing (via `/sync`)
- Handles the generation of "friendly" room and member names.
- Handles historical `RoomMember` information (e.g. display names).
- Manages room member state across multiple events (e.g. it handles typing, power
@@ -127,20 +147,20 @@ events for incoming data and state changes. Aside from wrapping the HTTP API, it
- Handles room initial sync on accepting invites.
- Handles WebRTC calling.
Later versions of the SDK will:
- Expose a `RoomSummary` which would be suitable for a recents page.
- Provide different pluggable storage layers (e.g. local storage, database-backed)
# Usage
## Conventions
## Supported platforms
### Emitted events
`matrix-js-sdk` can be used in either Node.js applications (ensure you have the latest LTS version of Node.js installed),
or in browser applications, via a bundler such as Webpack or Vite.
The SDK will emit events using an `EventEmitter`. It also
emits object models (e.g. `Rooms`, `RoomMembers`) when they
are updated.
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
## Emitted events
The SDK raises notifications to the application using
[`EventEmitter`s](https://nodejs.org/api/events.html#class-eventemitter). The `MatrixClient` itself
implements `EventEmitter`, as do many of the high-level abstractions such as `Room` and `RoomMember`.
```javascript
// Listen for low-level MatrixEvents
@@ -161,45 +181,21 @@ client.on(RoomMemberEvent.Typing, function (event, member) {
client.startClient();
```
### Promises and Callbacks
## Entry points
Most of the methods in the SDK are asynchronous: they do not directly return a
result, but instead return a [Promise](http://documentup.com/kriskowal/q/)
which will be fulfilled in the future.
As well as the primary entry point (`matrix-js-sdk`), there are several other entry points which may be useful:
The typical usage is something like:
```javascript
matrixClient.someMethod(arg1, arg2).then(function(result) {
...
});
```
Alternatively, if you have a Node.js-style `callback(err, result)` function,
you can pass the result of the promise into it with something like:
```javascript
matrixClient.someMethod(arg1, arg2).nodeify(callback);
```
The main thing to note is that it is problematic to discard the result of a
promise-returning function, as that will cause exceptions to go unobserved.
Methods which return a promise show this in their documentation.
Many methods in the SDK support _both_ Node.js-style callbacks _and_ Promises,
via an optional `callback` argument. The callback support is now deprecated:
new methods do not include a `callback` argument, and in the future it may be
removed from existing methods.
## Low level types
There are some low level TypeScript types exported via the `matrix-js-sdk/lib/types` entrypoint to not bloat the main entrypoint.
| Entry point | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
| `matrix-js-sdk` | Primary entry point. High-level functionality, and lots of historical clutter in need of a cleanup. |
| `matrix-js-sdk/lib/crypto-api` | Cryptography functionality. |
| `matrix-js-sdk/lib/types` | Low-level types, reflecting data structures defined in the Matrix spec. |
| `matrix-js-sdk/lib/testing` | Test utilities, which may be useful in test code but should not be used in production code. |
## Examples
This section provides some useful code snippets which demonstrate the
core functionality of the SDK. These examples assume the SDK is setup like this:
core functionality of the SDK. These examples assume the SDK is set up like this:
```javascript
import * as sdk from "matrix-js-sdk";
@@ -215,10 +211,10 @@ const matrixClient = sdk.createClient({
### Automatically join rooms when invited
```javascript
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
if (member.membership === KnownMembership.Invite && member.userId === myUserId) {
matrixClient.joinRoom(member.roomId).then(function () {
console.log("Auto-joined %s", member.roomId);
matrixClient.on(RoomEvent.MyMembership, function (room, membership, prevMembership) {
if (membership === KnownMembership.Invite) {
matrixClient.joinRoom(room.roomId).then(function () {
console.log("Auto-joined %s", room.roomId);
});
}
});
@@ -306,6 +302,9 @@ Then visit `http://localhost:8005` to see the API docs.
# End-to-end encryption support
**This section is outdated.** Use of `libolm` is deprecated and we are replacing it with support
from the matrix-rust-sdk (https://github.com/element-hq/element-web/issues/21972).
The SDK supports end-to-end encryption via the Olm and Megolm protocols, using
[libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
application to make libolm available, via the `Olm` global.
+36
View File
@@ -0,0 +1,36 @@
import { KnipConfig } from "knip";
export default {
entry: [
"src/index.ts",
"src/types.ts",
"src/browser-index.ts",
"src/indexeddb-worker.ts",
"scripts/**",
"spec/**",
"release.sh",
// For now, we include all source files as entrypoints as we have been bad about gutwrenched imports
"src/**",
],
project: ["**/*.{js,ts}"],
ignore: ["examples/**"],
ignoreDependencies: [
// Required for `action-validator`
"@action-validator/*",
// Used for git pre-commit hooks
"husky",
// Used in script which only runs in environment with `@octokit/rest` installed
"@octokit/rest",
// Used by jest
"jest-environment-jsdom",
"babel-jest",
"ts-node",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
"dist",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "31.6.0-rc.0",
"version": "32.4.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -19,6 +19,7 @@
"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 \"{}\"'",
"lint:knip": "knip",
"test": "jest",
"test:watch": "jest --watch",
"coverage": "yarn test --coverage"
@@ -52,7 +53,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.6.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -80,7 +81,6 @@
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
@@ -102,11 +102,11 @@
"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-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^48.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
@@ -115,12 +115,13 @@
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"knip": "^5.0.0",
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.2.5",
"rimraf": "^5.0.0",
"ts-node": "^10.9.1",
"ts-node": "^10.9.2",
"typedoc": "^0.25.10",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc-plugin-mdn-links": "^3.0.3",
+88 -23
View File
@@ -81,33 +81,37 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
};
}
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
beforeEach(
async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
cryptoCallbacks: createCryptoCallbacks(),
});
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
cryptoCallbacks: createCryptoCallbacks(),
});
syncResponder = new SyncResponder(homeserverUrl);
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
/** an object which intercepts `/keys/upload` requests on the test homeserver */
new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
/** an object which intercepts `/keys/upload` requests on the test homeserver */
new E2EKeyReceiver(homeserverUrl);
// Silence warnings from the backup manager
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
status: 404,
body: { errcode: "M_NOT_FOUND" },
});
// Silence warnings from the backup manager
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
status: 404,
body: { errcode: "M_NOT_FOUND" },
});
await initCrypto(aliceClient);
});
await initCrypto(aliceClient);
},
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
10000,
);
afterEach(async () => {
await aliceClient.stopClient();
@@ -343,6 +347,67 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
expect(isCrossSigningReady).toBeTruthy();
});
it("should return false if identity is not trusted, even if the secrets are in 4S", async () => {
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
// Complete initial sync, to get the 4S account_data events stored
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// For this test we need to have a well-formed 4S setup.
const mockSecretInfo = {
encrypted: {
// Don't care about the actual values here, just need to be present for validation
KeyId: {
iv: "IVIVIVIVIVIVIV",
ciphertext: "CIPHERTEXTB64",
mac: "MACMACMAC",
},
},
};
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.secret_storage.key.KeyId",
content: {
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
// iv and mac not relevant for this test
},
},
{
type: "m.secret_storage.default_key",
content: {
key: "KeyId",
},
},
{
type: "m.cross_signing.master",
content: mockSecretInfo,
},
{
type: "m.cross_signing.user_signing",
content: mockSecretInfo,
},
{
type: "m.cross_signing.self_signing",
content: mockSecretInfo,
},
],
},
});
await aliceClient.startClient();
await syncPromise(aliceClient);
// Sanity: ensure that the secrets are in 4S
const status = await aliceClient.getCrypto()!.getCrossSigningStatus();
expect(status.privateKeysInSecretStorage).toBeTruthy();
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeFalsy();
});
});
describe("getCrossSigningKeyId", () => {
+185 -49
View File
@@ -26,6 +26,7 @@ import * as testUtils from "../../test-utils/test-utils";
import {
advanceTimersUntil,
CRYPTO_BACKENDS,
emitPromise,
getSyncResponse,
InitCrypto,
mkEventCustom,
@@ -50,6 +51,7 @@ import {
ClientEvent,
createClient,
CryptoEvent,
HistoryVisibility,
IClaimOTKsResult,
IContent,
IDownloadKeyResult,
@@ -59,11 +61,11 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
MsgType,
PendingEventOrdering,
Room,
RoomMember,
RoomStateEvent,
HistoryVisibility,
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
@@ -80,12 +82,12 @@ import { SecretStorageKeyDescription } from "../../../src/secret-storage";
import {
CrossSigningKey,
CryptoCallbacks,
DecryptionFailureCode,
EventShieldColour,
EventShieldReason,
KeyBackupInfo,
} from "../../../src/crypto-api";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { DecryptionError } from "../../../src/crypto/algorithms";
import { IKeyBackup } from "../../../src/crypto/backup";
import {
createOlmAccount,
@@ -99,6 +101,7 @@ import {
} from "./olm-utils";
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
import { KnownMembership } from "../../../src/@types/membership";
afterEach(() => {
@@ -106,6 +109,8 @@ afterEach(() => {
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
jest.useRealTimers();
});
/**
@@ -465,56 +470,58 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("Unable to decrypt error codes", function () {
it("Encryption fails with expected UISI error", async () => {
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
it("Decryption fails with UISI error", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const awaitUISI = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
if (error.code == "MEGOLM_UNKNOWN_INBOUND_SESSION_ID") {
resolve();
}
});
});
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
// Ensure that the timestamp post-dates the creation of our device
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now(),
};
// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
await awaitUISI;
const ev = await awaitDecryption;
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID);
});
it("Encryption fails with expected Unknown Index error", async () => {
it("Decryption fails with Unknown Index error", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const awaitUnknownIndex = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
if (error.code == "OLM_UNKNOWN_MESSAGE_INDEX") {
resolve();
}
});
});
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]);
// Alice gets both the events in a single sync
// Ensure that the timestamp post-dates the creation of our device
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now(),
};
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
@@ -522,23 +529,168 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
await awaitUnknownIndex;
const ev = await awaitDecryption;
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
});
it("Encryption fails with Unable to decrypt for other errors", async () => {
describe("Historical events", () => {
async function sendEventAndAwaitDecryption(props: Partial<IEvent> = {}): Promise<MatrixEvent> {
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
// Ensure that the timestamp pre-dates the creation of our device: set it to 24 hours ago
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now() - 24 * 3600 * 1000,
...props,
};
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
syncResponder.sendOrQueueSyncResponse(syncResponse);
return await awaitDecryption;
}
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
});
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(
DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
);
});
newBackendOnly("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => {
// The test backup data is signed by a dummy device. We'll need to tell Alice about the device, and
// later, tell her to trust it, so that she trusts the backup.
const e2eResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
e2eResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
await startClientAndAwaitFirstSync();
await aliceClient
.getCrypto()!
.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
// Tell Alice to trust the dummy device that signed the backup
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// Tell Alice to check and enable backup
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
// Sanity: Alice should now have working backup.
expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual(
testData.SIGNED_BACKUP_DATA.version,
);
// Finally! we can check what happens when we get an event.
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
});
newBackendOnly("fails with NOT_JOINED if user is not member of room", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "leave",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
});
newBackendOnly(
"fails with another error when the server reports user was a member of the room",
async () => {
// This tests that when the server reports that the user
// was invited at the time the event was sent, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
// and instead get some other error, since the user should
// have gotten the key for the event.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "invite",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
newBackendOnly(
"fails with another error when the server reports user was a member of the room",
async () => {
// This tests that when the server reports the user's
// membership, and reports that the user was joined, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
// instead get some other error.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "join",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
});
it("Decryption fails with Unable to decrypt for other errors", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
await aliceClient.getCrypto()!.importRoomKeys([testData.MEGOLM_SESSION_DATA]);
const awaitDecryptionError = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
aliceClient.on(MatrixEventEvent.Decrypted, (ev) => {
// rust and libolm can't have an exact 1:1 mapping for all errors,
// but some errors are part of API and should match
if (
error.code != "MEGOLM_UNKNOWN_INBOUND_SESSION_ID" &&
error.code != "OLM_UNKNOWN_MESSAGE_INDEX"
ev.decryptionFailureReason !== DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID &&
ev.decryptionFailureReason !== DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX
) {
resolve();
}
@@ -1008,10 +1160,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
return encryptedMessage;
}
afterEach(() => {
jest.useRealTimers();
});
newBackendOnly("should rotate the session after 2 messages", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -1928,7 +2076,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expectAliceKeyQuery({ device_keys: { "@other:user": {} }, failures: {} });
aliceClient.on(RoomStateEvent.NewMember, (_e, _s, member: RoomMember) => {
if (member.userId == "@other:user") {
aliceClient.sendMessage(testRoomId, { msgtype: "m.text", body: "Hello, World" });
aliceClient.sendMessage(testRoomId, { msgtype: MsgType.Text, body: "Hello, World" });
}
});
@@ -2195,10 +2343,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
jest.useRealTimers();
});
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
@@ -2261,10 +2405,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("getUserDeviceInfo", () => {
afterEach(() => {
jest.useRealTimers();
});
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
const queryResponseBody = {
@@ -2753,10 +2893,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
jest.useRealTimers();
});
it("Should be able to restore from 4S after bootstrap", async () => {
const backupVersion = "1";
await bootstrapSecurity(backupVersion);
@@ -0,0 +1,181 @@
/*
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 fetchMock from "fetch-mock-jest";
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
describe("Device dehydration", () => {
it("should rehydrate and dehydrate a device", async () => {
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
cryptoCallbacks: {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
},
});
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
// count the number of times the dehydration key gets set
let setDehydrationCount = 0;
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
if (event.getType() === "org.matrix.msc3814") {
setDehydrationCount++;
}
});
const crypto = matrixClient.getCrypto()!;
fetchMock.config.overwriteRoutes = true;
// start dehydration -- we start with no dehydrated device, and we
// store the dehydrated device that we create
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
let dehydratedDeviceBody: any;
let dehydrationCount = 0;
let resolveDehydrationPromise: () => void;
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
dehydratedDeviceBody = JSON.parse(opts.body as string);
dehydrationCount++;
if (resolveDehydrationPromise) {
resolveDehydrationPromise();
}
return {};
});
await crypto.startDehydration();
expect(dehydrationCount).toEqual(1);
// a week later, we should have created another dehydrated device
const dehydrationPromise = new Promise<void>((resolve, reject) => {
resolveDehydrationPromise = resolve;
});
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
await dehydrationPromise;
expect(dehydrationCount).toEqual(2);
// restart dehydration -- rehydrate the device that we created above,
// and create a new dehydrated device. We also set `createNewKey`, so
// a new dehydration key will be set
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
device_id: dehydratedDeviceBody.device_id,
device_data: dehydratedDeviceBody.device_data,
});
const eventsResponse = jest.fn((url, opts) => {
// rehydrating should make two calls to the /events endpoint.
// The first time will return a single event, and the second
// time will return no events (which will signal to the
// rehydration function that it can stop)
const body = JSON.parse(opts.body as string);
const nextBatch = body.next_batch ?? "0";
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
return {
events,
next_batch: nextBatch + "1",
};
});
fetchMock.post(
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
eventsResponse,
);
await crypto.startDehydration(true);
expect(dehydrationCount).toEqual(3);
expect(setDehydrationCount).toEqual(2);
expect(eventsResponse.mock.calls).toHaveLength(2);
matrixClient.stopClient();
});
});
/** create a new secret storage and cross-signing keys */
async function initializeSecretStorage(
matrixClient: MatrixClient,
userId: string,
homeserverUrl: string,
): Promise<void> {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
const accountData: Map<string, object> = new Map();
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
const name = url.split("/").pop()!;
const value = accountData.get(name);
if (value) {
return value;
} else {
return {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
};
}
});
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
const name = url.split("/").pop()!;
const value = JSON.parse(opts.body as string);
accountData.set(name, value);
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
return {};
});
await matrixClient.initRustCrypto();
const crypto = matrixClient.getCrypto()! as RustCrypto;
// we need to process a sync so that the OlmMachine will upload keys
await crypto.preprocessToDeviceMessages([]);
await crypto.onSyncCompleted({});
// create initial secret storage
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
await matrixClient.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: false,
});
}
+32 -19
View File
@@ -45,6 +45,7 @@ import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keyback
import { IKeyBackup } from "../../../src/crypto/backup";
import { flushPromises } from "../../test-utils/flushPromises";
import { defer, IDeferred } from "../../../src/utils";
import { DecryptionFailureCode } from "../../../src/crypto-api";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -192,28 +193,31 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
}
}
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
beforeEach(
async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// ignore requests to send room key requests
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
// ignore requests to send room key requests
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
// start after saving the private key
await aliceClient.startClient();
// start after saving the private key
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
});
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
} /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */,
10000,
);
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
@@ -239,8 +243,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
// On the first decryption attempt, decryption fails.
await awaitDecryption(event);
expect(event.decryptionFailureReason).toEqual(
backend === "libolm"
? DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID
: DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP,
);
// Eventually, decryption succeeds.
await awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
});
+2 -2
View File
@@ -34,7 +34,7 @@ import { logger } from "../../../src/logger";
import * as testUtils from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent, MsgType } from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { KnownMembership } from "../../../src/@types/membership";
@@ -217,7 +217,7 @@ async function expectBobSendMessageRequest(): Promise<OlmPayload> {
}
function sendMessage(client: MatrixClient): Promise<ISendEventResponse> {
return client.sendMessage(roomId, { msgtype: "m.text", body: "Hello, World" });
return client.sendMessage(roomId, { msgtype: MsgType.Text, body: "Hello, World" });
}
async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise<IContent> {
+104
View File
@@ -162,6 +162,110 @@ describe("MatrixClient.initRustCrypto", () => {
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
}, 60000);
describe("Private key backup migration", () => {
it("should not migrate the backup private key if backup has changed", async () => {
// Here we have a new backup server side, and the migrated account has the previous backup key.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.newBackupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should not migrate the backup private key if backup has unknown algorithm", async () => {
// Here we have a new backup server side, and the migrated account has the previous backup key.
const backupResponse = {
...MSK_NOT_CACHED_DATASET.backupResponse,
algorithm: "m.megolm_backup.v8",
};
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should not migrate the backup private key if the backup has been deleted", async () => {
// The old backup has been deleted server side.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "No backup found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should migrate the backup private key if the backup matches", async () => {
// The old backup has been deleted server side.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.backupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeDefined();
});
});
describe("Legacy trust migration", () => {
async function populateAndStartLegacyCryptoStore(dumpPath: string): Promise<IndexedDBCryptoStore> {
const testStoreName = "test-store";
+106 -1
View File
@@ -19,7 +19,16 @@ import { Mocked } from "jest-mock";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
import { Filter, KnockRoomOpts, MemoryStore, Method, Room, SERVICE_TYPES } from "../../src/matrix";
import {
Filter,
JoinRule,
KnockRoomOpts,
MemoryStore,
Method,
Room,
RoomSummary,
SERVICE_TYPES,
} from "../../src/matrix";
import { TestClient } from "../TestClient";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IFilterDefinition } from "../../src/filter";
@@ -1710,6 +1719,102 @@ describe("MatrixClient", function () {
await Promise.all([client.unbindThreePid("email", "alice@server.com"), httpBackend.flushAllExpected()]);
});
});
describe("getRoomSummary", () => {
const roomId = "!foo:bar";
const encodedRoomId = encodeURIComponent(roomId);
const roomSummary: RoomSummary = {
"room_id": roomId,
"name": "My Room",
"avatar_url": "",
"topic": "My room topic",
"world_readable": false,
"guest_can_join": false,
"num_joined_members": 1,
"room_type": "",
"join_rule": JoinRule.Public,
"membership": "leave",
"im.nheko.summary.room_version": "6",
"im.nheko.summary.encryption": "algo",
};
const prefix = "/_matrix/client/unstable/im.nheko.summary/";
const suffix = `summary/${encodedRoomId}`;
const deprecatedSuffix = `rooms/${encodedRoomId}/summary`;
const errorUnrecogStatus = 404;
const errorUnrecogBody = {
errcode: "M_UNRECOGNIZED",
error: "Unsupported endpoint",
};
const errorBadreqStatus = 400;
const errorBadreqBody = {
errcode: "M_UNKNOWN",
error: "Invalid request",
};
it("should respond with a valid room summary object", () => {
httpBackend.when("GET", prefix + suffix).respond(200, roomSummary);
const prom = client.getRoomSummary(roomId).then((response) => {
expect(response).toEqual(roomSummary);
});
httpBackend.flush("");
return prom;
});
it("should allow fallback to the deprecated endpoint", () => {
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
httpBackend.when("GET", prefix + deprecatedSuffix).respond(200, roomSummary);
const prom = client.getRoomSummary(roomId).then((response) => {
expect(response).toEqual(roomSummary);
});
httpBackend.flush("");
return prom;
});
it("should respond to unsupported path with error", () => {
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
httpBackend.when("GET", prefix + deprecatedSuffix).respond(errorUnrecogStatus, errorUnrecogBody);
const prom = client.getRoomSummary(roomId).then(
function (response) {
throw Error("request not failed");
},
function (error) {
expect(error.httpStatus).toEqual(errorUnrecogStatus);
expect(error.errcode).toEqual(errorUnrecogBody.errcode);
expect(error.message).toEqual(`MatrixError: [${errorUnrecogStatus}] ${errorUnrecogBody.error}`);
},
);
httpBackend.flush("");
return prom;
});
it("should respond to invalid path arguments with error", () => {
httpBackend.when("GET", prefix).respond(errorBadreqStatus, errorBadreqBody);
const prom = client.getRoomSummary("notAroom").then(
function (response) {
throw Error("request not failed");
},
function (error) {
expect(error.httpStatus).toEqual(errorBadreqStatus);
expect(error.errcode).toEqual(errorBadreqBody.errcode);
expect(error.message).toEqual(`MatrixError: [${errorBadreqStatus}] ${errorBadreqBody.error}`);
},
);
httpBackend.flush("");
return prom;
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
+3 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
import HttpBackend from "matrix-mock-request";
import { EventStatus, RoomEvent, MatrixClient, MatrixScheduler } from "../../src/matrix";
import { EventStatus, MatrixClient, MatrixScheduler, MsgType, RoomEvent } from "../../src/matrix";
import { Room } from "../../src/models/room";
import { TestClient } from "../TestClient";
@@ -60,7 +60,7 @@ describe("MatrixClient retrying", function () {
// send a couple of events; the second will be queued
const p1 = client!
.sendMessage(roomId, {
msgtype: "m.text",
msgtype: MsgType.Text,
body: "m1",
})
.then(
@@ -77,7 +77,7 @@ describe("MatrixClient retrying", function () {
// never gets resolved.
// https://github.com/matrix-org/matrix-js-sdk/issues/496
client!.sendMessage(roomId, {
msgtype: "m.text",
msgtype: MsgType.Text,
body: "m2",
});
+434 -52
View File
@@ -39,6 +39,7 @@ import {
IndexedDBStore,
RelationType,
EventType,
MatrixEventEvent,
} from "../../src";
import { ReceiptType } from "../../src/@types/read_receipts";
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
@@ -46,6 +47,7 @@ import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IActionsObject } from "../../src/pushprocessor";
import { KnownMembership } from "../../src/@types/membership";
describe("MatrixClient syncing", () => {
@@ -1646,6 +1648,99 @@ describe("MatrixClient syncing", () => {
});
});
it("should zero total notifications for threads when absent from the notifications object", async () => {
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
[THREAD_ID]: {
highlight_count: 2,
notification_count: 5,
},
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomOne);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {};
httpBackend!.when("GET", "/sync").respond(200, syncData);
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
});
it("should zero highlight notifications for threads in encrypted rooms", async () => {
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
[THREAD_ID]: {
highlight_count: 2,
notification_count: 5,
},
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomOne);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
[THREAD_ID]: {
highlight_count: 0,
notification_count: 0,
},
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
});
it("should not zero highlight notifications for threads in encrypted rooms", async () => {
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
[THREAD_ID]: {
highlight_count: 2,
notification_count: 5,
},
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomOne);
room!.hasEncryptionStateEvent = jest.fn().mockReturnValue(true);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
[THREAD_ID]: {
highlight_count: 0,
notification_count: 0,
},
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2);
});
it("caches unknown threads receipts and replay them when the thread is created", async () => {
const THREAD_ID = "$unknownthread:localhost";
@@ -1733,64 +1828,351 @@ describe("MatrixClient syncing", () => {
});
});
it("should apply encrypted notification logic for events within the same sync blob", async () => {
const roomId = "!room123:server";
const syncData = {
rooms: {
join: {
[roomId]: {
ephemeral: {
events: [],
},
timeline: {
events: [
utils.mkEvent({
room: roomId,
event: true,
skey: "",
type: EventType.RoomEncryption,
content: {},
}),
utils.mkMessage({
room: roomId,
user: otherUserId,
msg: "hello",
}),
],
},
state: {
events: [
utils.mkMembership({
room: roomId,
mship: KnownMembership.Join,
user: otherUserId,
}),
utils.mkMembership({
room: roomId,
mship: KnownMembership.Join,
user: selfUserId,
}),
utils.mkEvent({
type: "m.room.create",
room: roomId,
user: selfUserId,
content: {},
}),
],
describe("encrypted notification logic", () => {
let roomId: string;
let syncData: ISyncResponse;
beforeEach(() => {
roomId = "!room123:server";
syncData = {
rooms: {
join: {
[roomId]: {
ephemeral: {
events: [],
},
timeline: {
events: [
utils.mkEvent({
room: roomId,
event: true,
skey: "",
type: EventType.RoomEncryption,
content: {},
}),
utils.mkMessage({
room: roomId,
user: otherUserId,
msg: "hello",
}),
],
},
state: {
events: [
utils.mkMembership({
room: roomId,
mship: KnownMembership.Join,
user: otherUserId,
}),
utils.mkMembership({
room: roomId,
mship: KnownMembership.Join,
user: selfUserId,
}),
utils.mkEvent({
type: "m.room.create",
room: roomId,
user: selfUserId,
content: {},
}),
],
},
},
},
},
},
} as unknown as ISyncResponse;
} as unknown as ISyncResponse;
});
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
it("should apply encrypted notification logic for events within the same sync blob", async () => {
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
});
it("should recalculate highlights on unthreaded receipt for encrypted rooms", async () => {
const myUserId = client!.getUserId()!;
const firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
// add a receipt for the first event in the room (let's say the user has already read that one)
syncData.rooms.join[roomId].ephemeral.events = [
{
content: {
[firstEventId]: {
"m.read": {
[myUserId]: { ts: 1 },
},
},
},
type: "m.receipt",
},
];
// Now add a highlighting event after that receipt
const pingEvent = utils.mkMessage({
room: roomId,
user: otherUserId,
msg: client?.getUserId() + " ping",
}) as IRoomEvent;
syncData.rooms.join[roomId].timeline.events.push(pingEvent);
// fudge this to make it a highlight
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
if (ev.getId() === pingEvent.event_id) {
return {
notify: true,
tweaks: {
highlight: true,
},
};
}
return null;
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
// the room should now have one highlight since our receipt was before the ping message
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
});
it("should recalculate highlights on main thread receipt for encrypted rooms", async () => {
const myUserId = client!.getUserId()!;
const firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
// add a receipt for the first event in the room (let's say the user has already read that one)
syncData.rooms.join[roomId].ephemeral.events = [
{
content: {
[firstEventId]: {
"m.read": {
[myUserId]: { ts: 1, thread_id: "main" },
},
},
},
type: "m.receipt",
},
];
// Now add a highlighting event after that receipt
const pingEvent = utils.mkMessage({
room: roomId,
user: otherUserId,
msg: client?.getUserId() + " ping",
}) as IRoomEvent;
syncData.rooms.join[roomId].timeline.events.push(pingEvent);
// fudge this to make it a highlight
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
if (ev.getId() === pingEvent.event_id) {
return {
notify: true,
tweaks: {
highlight: true,
},
};
}
return null;
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
// the room should now have one highlight since our receipt was before the ping message
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
});
describe("notification processing in threads", () => {
let threadEvent1: IRoomEvent;
let threadEvent2: IRoomEvent;
let firstEventId: string;
beforeEach(() => {
firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
// Add a threaded event off of the first event
threadEvent1 = utils.mkEvent({
type: EventType.RoomMessage,
user: otherUserId,
room: roomId,
ts: 500,
content: {
"body": "first thread response",
"m.relates_to": {
"event_id": firstEventId,
"m.in_reply_to": {
event_id: firstEventId,
},
"rel_type": "io.element.thread",
},
},
}) as IRoomEvent;
syncData.rooms.join[roomId].timeline.events.push(threadEvent1);
// ...and another
threadEvent2 = utils.mkEvent({
type: EventType.RoomMessage,
user: otherUserId,
room: roomId,
ts: 1500,
content: {
"body": "second thread response",
"m.relates_to": {
"event_id": firstEventId,
"m.in_reply_to": {
event_id: firstEventId,
},
"rel_type": "io.element.thread",
},
},
}) as IRoomEvent;
syncData.rooms.join[roomId].timeline.events.push(threadEvent2);
// fudge to make these highlights
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
if ([threadEvent1.event_id, threadEvent2.event_id].includes(ev.getId()!)) {
return {
notify: true,
tweaks: {
highlight: true,
},
};
}
return null;
};
});
it("checks threads with notifications on unthreaded receipts", async () => {
const myUserId = client!.getUserId()!;
// add a receipt for a random, ficticious thread, otherwise the client will
// think that the thread is before any threaded receipts and ignore it.
syncData.rooms.join[roomId].ephemeral.events = [
{
content: {
[firstEventId]: {
"m.read": {
[myUserId]: { ts: 1, thread_id: "some_other_thread" },
},
},
},
type: "m.receipt",
},
];
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient({ threadSupport: true });
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
// pretend that the client has decrypted an event to trigger it to compute
// local notifications
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(firstEventId)!);
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(threadEvent1.event_id)!);
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(threadEvent2.event_id)!);
expect(room).toBeInstanceOf(Room);
// we should now have one highlight: the unread message that pings
expect(
room.getThreadUnreadNotificationCount(firstEventId, NotificationCountType.Highlight),
).toEqual(2);
const syncData2 = {
rooms: {
join: {
[roomId]: {
ephemeral: {
events: [
{
content: {
[firstEventId]: {
"m.read": {
[myUserId]: { ts: 1 },
},
},
},
type: "m.receipt",
},
],
},
},
},
},
} as unknown as ISyncResponse;
httpBackend!.when("GET", "/sync").respond(200, syncData2);
await Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]);
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0);
});
it("should recalculate highlights on threaded receipt for encrypted rooms", async () => {
const myUserId = client!.getUserId()!;
// add a receipt for the first message in the threadm leaving the second one unread
syncData.rooms.join[roomId].ephemeral.events = [
{
content: {
[threadEvent1.event_id]: {
"m.read": {
[myUserId]: { ts: 1, thread_id: firstEventId },
},
},
},
type: "m.receipt",
},
];
// fudge to make both thread replies highlights
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
if ([threadEvent1.event_id, threadEvent2.event_id].includes(ev.getId()!)) {
return {
notify: true,
tweaks: {
highlight: true,
},
};
}
return null;
};
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient({ threadSupport: true });
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
// pretend that the client has decrypted an event to trigger it to compute
// local notifications
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(firstEventId)!);
// the room should now have one highlight: the second thread message
expect(room.getThreadUnreadNotificationCount(firstEventId, NotificationCountType.Highlight)).toBe(
1,
);
});
});
});
});
@@ -152,7 +152,7 @@ describe("MatrixClient syncing", () => {
await client!.sendEvent(roomId, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: threadReply.getId(),
event_id: threadReply.getId()!,
key: "",
},
});
+1
View File
@@ -44,6 +44,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
token_endpoint: issuer + "token",
authorization_endpoint: issuer + "auth",
registration_endpoint: issuer + "registration",
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
@@ -245,6 +245,28 @@ const BACKUP_RESPONSE: KeyBackupInfo = {
count: 0,
};
/**
* This was generated by doing a backup reset on the account.
* This is a new valid backup for this account.
*/
const NEW_BACKUP_RESPONSE: KeyBackupInfo = {
auth_data: {
public_key: "CkDxWALi3lcChgjEZFEM6clYq5x768XBwsL++eaOzTI",
signatures: {
"@migration:localhost": {
"ed25519:YVEGEYPYWX":
"ZSYuQDdwgB9WKXQ+z5aWWfqSolBCGRw53kur1Vy956gFefgzCBkMbw5M0I2UgfU2Cukri7jZ4ig201zmLNmaAA",
"ed25519:rXCrBin/+xyh+yW//vWte+2UV0et1ZHTWfalp/Ekack":
"+UQ8EA507LoIqgK9rPsqPoGrj+iRBJeY2Oz0mMtXmVf8c1y8G0KWJNUWqvOysnOhsoJf1bt8ey48CxjjtSQ2AA",
},
},
},
version: "3",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
etag: "0",
count: 0,
};
/**
* A dataset containing the information for the tested user.
* To be used during tests.
@@ -256,5 +278,6 @@ export const MSK_NOT_CACHED_DATASET: DumpDataSetInfo = {
keyQueryResponse: KEY_QUERY_RESPONSE,
rotatedKeyQueryResponse: ROTATED_KEY_QUERY_RESPONSE,
backupResponse: BACKUP_RESPONSE,
newBackupResponse: NEW_BACKUP_RESPONSE,
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump/dump.json",
};
+24
View File
@@ -76,5 +76,29 @@ describe("ContentRepo", function () {
baseUrl + "/_matrix/media/v3/download/server.name/resourceid#automade",
);
});
it("should return an authenticated URL when requested", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, true, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
it("should force-enable allow_redirects when useAuthentication is set true", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, false, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
baseUrl +
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
});
});
+14
View File
@@ -75,6 +75,20 @@ class MockWidgetApi extends EventEmitter {
public transport = { reply: jest.fn() };
}
declare module "../../src/types" {
interface StateEvents {
"org.example.foo": {
hello: string;
};
}
interface TimelineEvents {
"org.matrix.rageshake_request": {
request_id: number;
};
}
}
describe("RoomWidgetClient", () => {
let widgetApi: MockedObject<WidgetApi>;
let client: MatrixClient;
+20 -15
View File
@@ -22,6 +22,7 @@ import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
EventType,
MsgType,
RelationType,
RoomCreateTypeField,
RoomType,
@@ -38,30 +39,31 @@ import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import {
ContentHelpers,
ClientPrefix,
ConditionKind,
ContentHelpers,
Direction,
EventTimeline,
EventTimelineSet,
getHttpUriForMxc,
ICreateRoomOpts,
IPushRule,
IRequestOpts,
MatrixError,
MatrixHttpApi,
MatrixScheduler,
Method,
Room,
EventTimelineSet,
PushRuleActionName,
TweakName,
Room,
RuleId,
IPushRule,
ConditionKind,
getHttpUriForMxc,
TweakName,
} from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
import {
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
POLICIES_ACCOUNT_EVENT_TYPE,
PolicyRecommendation,
PolicyScope,
} from "../../src/models/invites-ignorer";
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
@@ -72,6 +74,7 @@ import { StubStore } from "../../src/store/stub";
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { KnownMembership } from "../../src/@types/membership";
import { RoomMessageEventContent } from "../../src/@types/events";
jest.useFakeTimers();
@@ -383,6 +386,9 @@ describe("MatrixClient", function () {
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
);
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true, true),
);
});
});
@@ -566,7 +572,7 @@ describe("MatrixClient", function () {
describe("sendEvent", () => {
const roomId = "!room:example.org";
const body = "This is the body";
const content = { body };
const content = { body, msgtype: MsgType.Text } satisfies RoomMessageEventContent;
it("overload without threadId works", async () => {
const eventId = "$eventId:example.org";
@@ -661,12 +667,13 @@ describe("MatrixClient", function () {
const content = {
body,
"msgtype": MsgType.Text,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
},
};
} satisfies RoomMessageEventContent;
const room = new Room(roomId, client, userId);
mocked(store.getRoom).mockReturnValue(room);
@@ -1527,8 +1534,6 @@ describe("MatrixClient", function () {
{ startOpts: {}, hasThreadSupport: false },
{ startOpts: { threadSupport: true }, hasThreadSupport: true },
{ startOpts: { threadSupport: false }, hasThreadSupport: false },
{ startOpts: { experimentalThreadSupport: true }, hasThreadSupport: true },
{ startOpts: { experimentalThreadSupport: true, threadSupport: false }, hasThreadSupport: false },
])("enabled thread support for the SDK instance", async ({ startOpts, hasThreadSupport }) => {
await client.startClient(startOpts);
expect(client.supportsThreads()).toBe(hasThreadSupport);
@@ -2082,10 +2087,10 @@ describe("MatrixClient", function () {
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
// Add a rule in the new source room.
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, {
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, EventType.PolicyRuleUser, {
entity: "*:example.org",
reason: "just a test",
recommendation: "m.ban",
recommendation: PolicyRecommendation.Ban,
});
// We should reject this invite.
@@ -2172,8 +2177,8 @@ describe("MatrixClient", function () {
// Check where it shows up.
const targetRoomId = ignoreInvites2.target;
const targetRoom = client.getRoom(targetRoomId) as WrappedRoom;
expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy();
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
expect(targetRoom._state.get(EventType.PolicyRuleUser)[eventId]).toBeTruthy();
expect(newSourceRoom._state.get(EventType.PolicyRuleUser)?.[eventId]).toBeFalsy();
});
});
+3 -2
View File
@@ -26,6 +26,7 @@ import {
import { DEFAULT_ALPHABET } from "../../../src/utils";
import { MatrixError } from "../../../src/http-api";
import { KnownMembership } from "../../../src/@types/membership";
import { EncryptedFile } from "../../../src/@types/media";
describe("MSC3089TreeSpace", () => {
let client: MatrixClient;
@@ -947,7 +948,7 @@ describe("MSC3089TreeSpace", () => {
const fileInfo = {
mimetype: "text/plain",
// other fields as required by encryption, but ignored here
};
} as unknown as EncryptedFile;
const fileEventId = "$file";
const fileName = "My File.txt";
const fileContents = "This is a test file";
@@ -1007,7 +1008,7 @@ describe("MSC3089TreeSpace", () => {
const fileInfo = {
mimetype: "text/plain",
// other fields as required by encryption, but ignored here
};
} as unknown as EncryptedFile;
const fileEventId = "$file";
const fileName = "My File.txt";
const fileContents = "This is a test file";
+37 -49
View File
@@ -27,6 +27,8 @@ import {
THREAD_RELATION_TYPE,
TweakName,
} from "../../../src";
import { DecryptionFailureCode } from "../../../src/crypto-api";
import { DecryptionError } from "../../../src/common-crypto/CryptoBackend";
describe("MatrixEvent", () => {
it("should create copies of itself", () => {
@@ -360,20 +362,50 @@ describe("MatrixEvent", () => {
});
});
it("should report decryption errors", async () => {
it("should report unknown decryption errors", async () => {
const decryptionListener = jest.fn();
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
const testError = new Error("test error");
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
decryptEvent: jest.fn().mockRejectedValue(testError),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
});
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
});
it("should report known decryption errors", async () => {
const decryptionListener = jest.fn();
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
const testError = new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, "uisi");
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(testError),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.decryptionFailureReason).toEqual(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
);
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: DecryptionError: uisi **",
});
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
});
it(`should report "DecryptionError: The sender has disabled encrypting to unverified devices."`, async () => {
@@ -423,6 +455,8 @@ describe("MatrixEvent", () => {
expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2);
expect(crypto.decryptEvent).toHaveBeenCalledTimes(2);
expect(encryptedEvent.getType()).toEqual("m.room.message");
expect(encryptedEvent.isDecryptionFailure()).toBe(false);
expect(encryptedEvent.decryptionFailureReason).toBe(null);
});
});
@@ -469,52 +503,6 @@ describe("MatrixEvent", () => {
default: false,
enabled: true,
} as IAnnotatedPushRule;
describe("setPushActions()", () => {
it("sets actions on event", () => {
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushActions(actions);
expect(event.getPushActions()).toBe(actions);
});
it("sets actions to undefined", () => {
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushActions(null);
// undefined is set on state
expect(event.getPushDetails().actions).toBe(undefined);
// but pushActions getter returns null when falsy
expect(event.getPushActions()).toBe(null);
});
it("clears existing push rule", () => {
const prevActions = { notify: true, tweaks: { highlight: true } };
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushDetails(prevActions, pushRule);
event.setPushActions(actions);
// rule is not in event push cache
expect(event.getPushDetails()).toEqual({ actions });
});
});
describe("setPushDetails()", () => {
it("sets actions and rule on event", () => {
@@ -543,7 +531,7 @@ describe("MatrixEvent", () => {
});
event.setPushDetails(prevActions, pushRule);
event.setPushActions(actions);
event.setPushDetails(actions);
// rule is not in event push cache
expect(event.getPushDetails()).toEqual({ actions });
+27
View File
@@ -90,4 +90,31 @@ describe("registerOidcClient()", () => {
OidcError.DynamicRegistrationInvalid,
);
});
it("should throw when required endpoints are unavailable", async () => {
await expect(() =>
registerOidcClient(
{
...delegatedAuthConfig,
registrationEndpoint: undefined,
},
metadata,
),
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
});
it("should throw when required scopes are unavailable", async () => {
await expect(() =>
registerOidcClient(
{
...delegatedAuthConfig,
metadata: {
...delegatedAuthConfig.metadata,
grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]],
},
},
metadata,
),
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
});
});
+320 -38
View File
@@ -1,8 +1,35 @@
import * as utils from "../test-utils/test-utils";
import { IActionsObject, PushProcessor } from "../../src/pushprocessor";
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName, RuleId } from "../../src";
import {
ConditionKind,
EventType,
IContent,
IPushRule,
MatrixClient,
MatrixEvent,
PushRuleActionName,
RuleId,
TweakName,
} from "../../src";
import { mockClientMethodsUser } from "../test-utils/client";
const msc3914RoomCallRule: IPushRule = {
rule_id: ".org.matrix.msc3914.rule.room.call",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "org.matrix.msc3401.call",
},
{
kind: ConditionKind.CallStarted,
},
],
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }],
};
describe("NotificationService", function () {
const testUserId = "@ali:matrix.org";
const testDisplayName = "Alice M";
@@ -12,23 +39,6 @@ describe("NotificationService", function () {
let pushProcessor: PushProcessor;
const msc3914RoomCallRule = {
rule_id: ".org.matrix.msc3914.rule.room.call",
default: true,
enabled: true,
conditions: [
{
kind: "event_match",
key: "type",
pattern: "org.matrix.msc3401.call",
},
{
kind: "call_started",
},
],
actions: ["notify", { set_tweak: "sound", value: "default" }],
};
let matrixClient: MatrixClient;
beforeEach(function () {
@@ -187,26 +197,6 @@ describe("NotificationService", function () {
pushProcessor = new PushProcessor(matrixClient);
});
it("should add default rules in the correct order", () => {
// By the time we get here, we expect the PushProcessor to have merged the new .m.rule.is_room_mention rule into the existing list of rules.
// Check that has happened, and that it is in the right place.
const containsDisplayNameRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
(rule) => rule.rule_id === RuleId.ContainsDisplayName,
);
expect(containsDisplayNameRuleIdx).toBeGreaterThan(-1);
const isRoomMentionRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
(rule) => rule.rule_id === RuleId.IsRoomMention,
);
expect(isRoomMentionRuleIdx).toBeGreaterThan(-1);
const mReactionRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
(rule) => rule.rule_id === ".m.rule.reaction",
);
expect(mReactionRuleIdx).toBeGreaterThan(-1);
expect(containsDisplayNameRuleIdx).toBeLessThan(isRoomMentionRuleIdx!);
expect(isRoomMentionRuleIdx).toBeLessThan(mReactionRuleIdx!);
});
// User IDs
it("should bing on a user ID.", function () {
@@ -722,3 +712,295 @@ describe("Test PushProcessor.partsForDottedKey", function () {
expect(PushProcessor.partsForDottedKey(path)).toStrictEqual(expected);
});
});
describe("rewriteDefaultRules", () => {
it("should add default rules in the correct order", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
device: {},
global: {
content: [],
override: [
// Include user-defined push rules inbetween .m.rule.master and other default rules to assert they are maintained in-order.
{
rule_id: ".m.rule.master",
default: true,
enabled: false,
conditions: [],
actions: [],
},
{
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: "default",
},
{
set_tweak: TweakName.Highlight,
},
],
enabled: true,
pattern: "coffee",
rule_id: "coffee",
default: false,
},
{
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: "default",
},
{
set_tweak: TweakName.Highlight,
},
],
conditions: [
{
kind: ConditionKind.ContainsDisplayName,
},
],
enabled: true,
default: true,
rule_id: ".m.rule.contains_display_name",
},
{
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Sound,
value: "default",
},
],
conditions: [
{
is: "2",
kind: ConditionKind.RoomMemberCount,
},
],
enabled: true,
rule_id: ".m.rule.room_one_to_one",
default: true,
},
],
room: [],
sender: [],
underride: [
{
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: false,
},
],
conditions: [],
enabled: true,
rule_id: "user-defined",
default: false,
},
msc3914RoomCallRule,
{
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: false,
},
],
conditions: [],
enabled: true,
rule_id: ".m.rule.fallback",
default: true,
},
],
},
});
// By the time we get here, we expect the PushProcessor to have merged the new .m.rule.is_room_mention rule into the existing list of rules.
// Check that has happened, and that it is in the right place.
const containsDisplayNameRuleIdx = pushRules.global.override?.findIndex(
(rule) => rule.rule_id === RuleId.ContainsDisplayName,
);
expect(containsDisplayNameRuleIdx).toBeGreaterThan(-1);
const isRoomMentionRuleIdx = pushRules.global.override?.findIndex(
(rule) => rule.rule_id === RuleId.IsRoomMention,
);
expect(isRoomMentionRuleIdx).toBeGreaterThan(-1);
const mReactionRuleIdx = pushRules.global.override?.findIndex((rule) => rule.rule_id === ".m.rule.reaction");
expect(mReactionRuleIdx).toBeGreaterThan(-1);
expect(containsDisplayNameRuleIdx).toBeLessThan(isRoomMentionRuleIdx!);
expect(isRoomMentionRuleIdx).toBeLessThan(mReactionRuleIdx!);
expect(pushRules.global.override?.map((r) => r.rule_id)).toEqual([
".m.rule.master",
"coffee",
".m.rule.contains_display_name",
".m.rule.room_one_to_one",
".m.rule.is_room_mention",
".m.rule.reaction",
".org.matrix.msc3786.rule.room.server_acl",
]);
expect(pushRules.global.underride?.map((r) => r.rule_id)).toEqual([
"user-defined",
".org.matrix.msc3914.rule.room.call",
// Assert that unknown default rules are maintained
".m.rule.fallback",
]);
});
it("should add missing msc3914 rule in correct place", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
device: {},
global: {
// Sample push rules from a Synapse user.
// Note that rules 2 and 3 are backwards, this will trigger a warning in the console.
underride: [
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "m.call.invite",
},
],
actions: [
"notify",
{
set_tweak: "sound",
value: "ring",
},
{
set_tweak: "highlight",
value: false,
},
],
rule_id: ".m.rule.call",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "m.room.message",
},
{
kind: "room_member_count",
is: "2",
},
],
actions: [
"notify",
{
set_tweak: "sound",
value: "TEST1",
},
{
set_tweak: "highlight",
value: false,
},
],
rule_id: ".m.rule.room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "m.room.encrypted",
},
{
kind: "room_member_count",
is: "2",
},
],
actions: [
"notify",
{
set_tweak: "sound",
value: "TEST2",
},
{
set_tweak: "highlight",
value: false,
},
],
rule_id: ".m.rule.encrypted_room_one_to_one",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "m.room.message",
},
],
actions: ["dont_notify"],
rule_id: ".m.rule.message",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "m.room.encrypted",
},
],
actions: ["dont_notify"],
rule_id: ".m.rule.encrypted",
default: true,
enabled: true,
},
{
conditions: [
{
kind: "event_match",
key: "type",
pattern: "im.vector.modular.widgets",
},
{
kind: "event_match",
key: "content.type",
pattern: "jitsi",
},
{
kind: "event_match",
key: "state_key",
pattern: "*",
},
],
actions: [
"notify",
{
set_tweak: "highlight",
value: false,
},
],
rule_id: ".im.vector.jitsi",
default: true,
enabled: true,
},
] as IPushRule[],
},
});
expect(pushRules.global.underride?.map((r) => r.rule_id)).toEqual([
".m.rule.call",
".org.matrix.msc3914.rule.room.call",
".m.rule.room_one_to_one",
".m.rule.encrypted_room_one_to_one",
".m.rule.message",
".m.rule.encrypted",
".im.vector.jitsi",
]);
});
});
+5 -2
View File
@@ -25,10 +25,11 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
import { DecryptionError } from "../../src/crypto/algorithms";
import { defer } from "../../src/utils";
import { Room } from "../../src/models/room";
import { KnownMembership } from "../../src/@types/membership";
import { DecryptionFailureCode } from "../../src/crypto-api";
import { DecryptionError } from "../../src/common-crypto/CryptoBackend";
describe("RoomState", function () {
const roomId = "!foo:bar";
@@ -1040,7 +1041,9 @@ describe("RoomState", function () {
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
mockClient.decryptEventIfNeeded.mockRejectedValue(
new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "msg"),
);
// spy on event.once
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
+73 -23
View File
@@ -2831,11 +2831,41 @@ describe("Room", function () {
// XXX: If we add the relation to the thread response before the thread finishes fetching via /relations
// then the test will fail
await emitPromise(room, ThreadEvent.Update);
await emitPromise(room, ThreadEvent.Update);
await Promise.all([emitPromise(room, ThreadEvent.Update), room.addLiveEvents([threadResponseEdit])]);
expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
});
it("emits event for the first event added to a thread", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
await room.addLiveEvents([threadRoot]);
const onEvent = jest.fn();
room.on(RoomEvent.Timeline, onEvent);
await room.addLiveEvents([threadResponse1]);
expect(onEvent).toHaveBeenCalled();
});
it("contains the events added as soon as it's created", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const newThreadEventPromise = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1]);
const thread = await newThreadEventPromise;
expect(thread.timeline).toContain(threadResponse1);
});
it("Redactions to thread responses decrement the length", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
@@ -2864,7 +2894,6 @@ describe("Room", function () {
let prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
@@ -2929,6 +2958,10 @@ describe("Room", function () {
},
});
room.client.fetchRelations = jest.fn().mockResolvedValue({
chunk: [threadResponse2Reaction.event, threadResponse2.event, threadResponse1.event],
});
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom;
@@ -2969,18 +3002,20 @@ describe("Room", function () {
},
});
let prom = emitPromise(room, ThreadEvent.New);
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
prom = emitPromise(room, ThreadEvent.Update);
const threadRootRedaction = mkRedaction(threadRoot);
await room.addLiveEvents([threadRootRedaction]);
await prom;
// We can't wait for a thread update here because there shouldn't be one (which is
// what we're asserting). Flush any promises to try to get more certainty that an
// update is not happening some time after the event is added.
await flushPromises();
expect(thread).toHaveLength(2);
});
@@ -3058,7 +3093,6 @@ describe("Room", function () {
await emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2);
await emitPromise(room, ThreadEvent.Update);
await room.addLiveEvents([threadResponse2Redaction]);
expect(thread).toHaveLength(1);
expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId());
@@ -3514,7 +3548,7 @@ describe("Room", function () {
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCount();
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBe(null);
@@ -3531,16 +3565,6 @@ describe("Room", function () {
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
});
it("partially resets room notifications", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
room.setThreadUnreadNotificationCount("456", NotificationCountType.Highlight, 123);
room.resetThreadUnreadNotificationCount(["123"]);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(666);
expect(room.getThreadUnreadNotificationCount("456", NotificationCountType.Highlight)).toBe(0);
});
it("emits event on notifications reset", () => {
const cb = jest.fn();
@@ -3549,7 +3573,7 @@ describe("Room", function () {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
room.setThreadUnreadNotificationCount("456", NotificationCountType.Highlight, 123);
room.resetThreadUnreadNotificationCount();
room.resetThreadUnreadNotificationCountFromSync();
expect(cb).toHaveBeenLastCalledWith();
});
@@ -3571,10 +3595,10 @@ describe("Room", function () {
});
it("lets you reset", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 1);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
expect(room.hasThreadUnreadNotification()).toBe(true);
room.resetThreadUnreadNotificationCount();
room.resetThreadUnreadNotificationCountFromSync();
expect(room.hasThreadUnreadNotification()).toBe(false);
});
@@ -3602,12 +3626,38 @@ describe("Room", function () {
it("allows reset", () => {
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBeNull();
});
it("retains highlight for encrypted rooms on reset", () => {
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(true);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCount();
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBeNull();
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
});
it("resets highlight for unencrypted rooms on reset", () => {
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(false);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBe(null);
expect(room.getThreadUnreadNotificationCount("$123", NotificationCountType.Highlight)).toBe(0);
});
});
@@ -22,6 +22,7 @@ import {
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
PutDehydratedDeviceRequest,
RoomMessageRequest,
SignatureUploadRequest,
UploadSigningKeysRequest,
@@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle PutDehydratedDeviceRequest", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq));
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request.
const testResponse = '{"result":1}';
httpBackend
.when("PUT", "/_matrix")
.check((req) => {
expect(req.path).toEqual(
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
);
expect(JSON.parse(req.rawData)).toEqual(testReq);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
await httpBackend.flushAllExpected();
await reqProm;
httpBackend.verifyNoOutstandingRequests();
});
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
const markSentCallPromise = awaitCallToMarkAsSent();
@@ -90,7 +90,7 @@ describe("PerSessionKeyBackupDownloader", () => {
mockRustBackupManager = {
getActiveBackupVersion: jest.fn(),
requestKeyBackupVersion: jest.fn(),
getServerBackupInfo: jest.fn(),
importBackedUpRoomKeys: jest.fn(),
createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor),
on: jest.fn().mockImplementation((event, listener) => {
@@ -135,7 +135,7 @@ describe("PerSessionKeyBackupDownloader", () => {
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
});
it("Should download and import a missing key from backup", async () => {
@@ -155,8 +155,11 @@ describe("PerSessionKeyBackupDownloader", () => {
downloader.onDecryptionKeyMissingError(roomId, sessionId);
// `isKeyBackupDownloadConfigured` is false until the config is proven.
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
await expectAPICall;
await awaitKeyImported.promise;
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1);
});
@@ -313,6 +316,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should not query server if backup not active", async () => {
@@ -328,6 +334,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key is not cached", async () => {
@@ -344,6 +353,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key cached as wrong version", async () => {
@@ -363,6 +375,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key version does not match the active one", async () => {
@@ -382,13 +397,16 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
});
describe("Given Backup state update", () => {
it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// but at this point it's not trusted and we don't have the key
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
@@ -410,6 +428,7 @@ describe("PerSessionKeyBackupDownloader", () => {
// @ts-ignore access to private property
expect(downloader.hasConfigurationProblem).toEqual(true);
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
// Now the backup becomes trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
@@ -423,6 +442,7 @@ describe("PerSessionKeyBackupDownloader", () => {
mockEmitter.emit(CryptoEvent.KeyBackupStatus, true);
await jest.runAllTimersAsync();
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
await a0Imported;
await a1Imported;
@@ -434,7 +454,7 @@ describe("PerSessionKeyBackupDownloader", () => {
describe("Error cases", () => {
beforeEach(async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// It's trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// And we have the key in cache
+97 -3
View File
@@ -205,7 +205,20 @@ describe("initRustCrypto", () => {
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
auth_data: {
public_key: "backup_key_public",
},
version: "45",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
});
// The cached key should be valid for the backup
const mockBackupDecryptionKey: any = {
megolmV1PublicKey: {
publicKeyBase64: "backup_key_public",
},
};
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
@@ -291,6 +304,56 @@ describe("initRustCrypto", () => {
}
}, 10000);
it("migrates data from a legacy crypto store when secret are not encrypted", async () => {
const PICKLE_KEY = "pickle1234";
const legacyStore = new MemoryCryptoStore();
// It's possible for old sessions to directly store the secrets as raw UInt8Array,
// so we need to support that in the migration code.
// See https://github.com/matrix-org/matrix-js-sdk/commit/c81f11df0afd4d0da3b088892745ae2f8ba1c4a7
async function storeSecretKeyInClear(type: string, key: Uint8Array, store: CryptoStore) {
// @ts-ignore The API to store raw UInt8Array does not exist anymore, so we need that for this test.
store.privateKeys[type as keyof SecretStorePrivateKeys] = key;
}
// Populate the legacy store with some test data
const storeSecretKey = (type: string, key: string) =>
storeSecretKeyInClear(type, new TextEncoder().encode(key), legacyStore);
await legacyStore.storeAccount({}, "not a real account");
await storeSecretKey("master", "master key");
await storeSecretKey("self_signing", "ssk");
await storeSecretKey("user_signing", "usk");
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
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,
});
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.privateCrossSigningMasterKey!)).toEqual("master key");
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
});
it("handles megolm sessions with no `keysClaimed`", async () => {
const legacyStore = new MemoryCryptoStore();
legacyStore.storeAccount({}, "not a real account");
@@ -762,8 +825,11 @@ describe("RustCrypto", () => {
},
},
};
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
} else if (
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
) {
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
return;
}
if (request.id) {
@@ -1395,6 +1461,34 @@ describe("RustCrypto", () => {
});
});
});
describe("device dehydration", () => {
it("should detect if dehydration is supported", async () => {
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
fetchMock.config.overwriteRoutes = true;
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_UNRECOGNIZED",
error: "Unknown endpoint",
},
});
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
device_id: "DEVICE_ID",
device_data: "data",
});
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
});
});
});
/** Build a MatrixHttpApi instance */
+120
View File
@@ -0,0 +1,120 @@
/*
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 {
decryptExistingEvent,
mkDecryptionFailureMatrixEvent,
mkEncryptedMatrixEvent,
mkMatrixEvent,
} from "../../src/testing";
import { EventType } from "../../src";
import { DecryptionFailureCode } from "../../src/crypto-api";
describe("testing", () => {
describe("mkMatrixEvent", () => {
it("makes an event", () => {
const event = mkMatrixEvent({
content: { body: "blah" },
sender: "@alice:test",
type: EventType.RoomMessage,
roomId: "!test:room",
});
expect(event.getContent()).toEqual({ body: "blah" });
expect(event.sender?.userId).toEqual("@alice:test");
expect(event.isState()).toBe(false);
});
it("makes a state event", () => {
const event = mkMatrixEvent({
content: { body: "blah" },
sender: "@alice:test",
type: EventType.RoomTopic,
roomId: "!test:room",
stateKey: "",
});
expect(event.getContent()).toEqual({ body: "blah" });
expect(event.sender?.userId).toEqual("@alice:test");
expect(event.isState()).toBe(true);
expect(event.getStateKey()).toEqual("");
});
});
describe("mkEncryptedMatrixEvent", () => {
it("makes an event", async () => {
const event = await mkEncryptedMatrixEvent({
plainContent: { body: "blah" },
sender: "@alice:test",
plainType: EventType.RoomMessage,
roomId: "!test:room",
});
expect(event.sender?.userId).toEqual("@alice:test");
expect(event.isEncrypted()).toBe(true);
expect(event.isDecryptionFailure()).toBe(false);
expect(event.decryptionFailureReason).toBe(null);
expect(event.getContent()).toEqual({ body: "blah" });
expect(event.getType()).toEqual("m.room.message");
});
});
describe("mkDecryptionFailureMatrixEvent", () => {
it("makes an event", async () => {
const event = await mkDecryptionFailureMatrixEvent({
sender: "@alice:test",
roomId: "!test:room",
code: DecryptionFailureCode.UNKNOWN_ERROR,
msg: "blah",
});
expect(event.sender?.userId).toEqual("@alice:test");
expect(event.isEncrypted()).toBe(true);
expect(event.isDecryptionFailure()).toBe(true);
expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
expect(event.getContent()).toEqual({
body: "** Unable to decrypt: DecryptionError: blah **",
msgtype: "m.bad.encrypted",
});
expect(event.getType()).toEqual("m.room.message");
expect(event.isState()).toBe(false);
});
});
describe("decryptExistingEvent", () => {
it("decrypts an event", async () => {
const event = await mkDecryptionFailureMatrixEvent({
sender: "@alice:test",
roomId: "!test:room",
code: DecryptionFailureCode.UNKNOWN_ERROR,
msg: "blah",
});
expect(event.isEncrypted()).toBe(true);
expect(event.isDecryptionFailure()).toBe(true);
await decryptExistingEvent(event, {
plainContent: { body: "blah" },
plainType: "m.room.test",
});
expect(event.isEncrypted()).toBe(true);
expect(event.isDecryptionFailure()).toBe(false);
expect(event.decryptionFailureReason).toBe(null);
expect(event.getContent()).toEqual({ body: "blah" });
expect(event.getType()).toEqual("m.room.test");
});
});
});
+5
View File
@@ -15,3 +15,8 @@ limitations under the License.
*/
export type NonEmptyArray<T> = [T, ...T[]];
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
+123 -13
View File
@@ -15,6 +15,49 @@ limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue";
import {
PolicyRuleEventContent,
RoomAvatarEventContent,
RoomCanonicalAliasEventContent,
RoomCreateEventContent,
RoomEncryptionEventContent,
RoomGuestAccessEventContent,
RoomHistoryVisibilityEventContent,
RoomJoinRulesEventContent,
RoomMemberEventContent,
RoomNameEventContent,
RoomPinnedEventsEventContent,
RoomPowerLevelsEventContent,
RoomServerAclEventContent,
RoomThirdPartyInviteEventContent,
RoomTombstoneEventContent,
RoomTopicEventContent,
SpaceChildEventContent,
SpaceParentEventContent,
} from "./state_events";
import {
ExperimentalGroupCallRoomMemberState,
IGroupCallRoomMemberState,
IGroupCallRoomState,
} from "../webrtc/groupCall";
import { MSC3089EventContent } from "../models/MSC3089Branch";
import { M_BEACON, M_BEACON_INFO, MBeaconEventContent, MBeaconInfoEventContent } from "./beacon";
import { XOR } from "./common";
import { ReactionEventContent, RoomMessageEventContent, StickerEventContent } from "./events";
import {
MCallAnswer,
MCallBase,
MCallCandidates,
MCallHangupReject,
MCallInviteNegotiate,
MCallReplacesEvent,
MCallSelectAnswer,
SDPStreamMetadata,
SDPStreamMetadataKey,
} from "../webrtc/callEventTypes";
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
import { EncryptedFile } from "./media";
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
export enum EventType {
// Room state events
@@ -35,6 +78,11 @@ export enum EventType {
RoomTombstone = "m.room.tombstone",
RoomPredecessor = "org.matrix.msc3946.room_predecessor",
// Moderation policy lists
PolicyRuleUser = "m.policy.rule.user",
PolicyRuleRoom = "m.policy.rule.room",
PolicyRuleServer = "m.policy.rule.server",
SpaceChild = "m.space.child",
SpaceParent = "m.space.parent",
@@ -103,6 +151,10 @@ export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
Reference = "m.reference",
// Don't use this yet: it's only the stable version. The code still assumes we support the unstable prefix and,
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
Thread = "m.thread",
}
@@ -246,17 +298,75 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
*/
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
export interface IEncryptedFile {
url: string;
mimetype?: string;
key: {
alg: string;
key_ops: string[]; // eslint-disable-line camelcase
kty: string;
k: string;
ext: boolean;
};
iv: string;
hashes: { [alg: string]: string };
v: string;
/**
* https://github.com/matrix-org/matrix-spec-proposals/pull/4115
*
* @experimental
*/
export const UNSIGNED_MEMBERSHIP_FIELD = new UnstableValue("membership", "io.element.msc4115.membership");
/**
* @deprecated in favour of {@link EncryptedFile}
*/
export type IEncryptedFile = EncryptedFile;
/**
* Mapped type from event type to content type for all specified non-state room events.
*/
export interface TimelineEvents {
[EventType.RoomMessage]: RoomMessageEventContent;
[EventType.Sticker]: StickerEventContent;
[EventType.Reaction]: ReactionEventContent;
[EventType.CallReplaces]: MCallReplacesEvent;
[EventType.CallAnswer]: MCallAnswer;
[EventType.CallSelectAnswer]: MCallSelectAnswer;
[EventType.CallNegotiate]: Omit<MCallInviteNegotiate, "offer">;
[EventType.CallInvite]: MCallInviteNegotiate;
[EventType.CallCandidates]: MCallCandidates;
[EventType.CallHangup]: MCallHangupReject;
[EventType.CallReject]: MCallHangupReject;
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
}
/**
* Mapped type from event type to content type for all specified room state events.
*/
export interface StateEvents {
[EventType.RoomCanonicalAlias]: RoomCanonicalAliasEventContent;
[EventType.RoomCreate]: RoomCreateEventContent;
[EventType.RoomJoinRules]: RoomJoinRulesEventContent;
[EventType.RoomMember]: RoomMemberEventContent;
// XXX: Spec says this event has 3 required fields but kicking such an invitation requires sending `{}`
[EventType.RoomThirdPartyInvite]: XOR<RoomThirdPartyInviteEventContent, {}>;
[EventType.RoomPowerLevels]: RoomPowerLevelsEventContent;
[EventType.RoomName]: RoomNameEventContent;
[EventType.RoomTopic]: RoomTopicEventContent;
[EventType.RoomAvatar]: RoomAvatarEventContent;
[EventType.RoomPinnedEvents]: RoomPinnedEventsEventContent;
[EventType.RoomEncryption]: RoomEncryptionEventContent;
[EventType.RoomHistoryVisibility]: RoomHistoryVisibilityEventContent;
[EventType.RoomGuestAccess]: RoomGuestAccessEventContent;
[EventType.RoomServerAcl]: RoomServerAclEventContent;
[EventType.RoomTombstone]: RoomTombstoneEventContent;
[EventType.SpaceChild]: SpaceChildEventContent;
[EventType.SpaceParent]: SpaceParentEventContent;
[EventType.PolicyRuleUser]: XOR<PolicyRuleEventContent, {}>;
[EventType.PolicyRuleRoom]: XOR<PolicyRuleEventContent, {}>;
[EventType.PolicyRuleServer]: XOR<PolicyRuleEventContent, {}>;
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
// MSC3672
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
}
+119
View File
@@ -0,0 +1,119 @@
/*
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 { MsgType, RelationType } from "./event";
import { FileInfo, ImageInfo, MediaEventContent } from "./media";
import { XOR } from "./common";
interface BaseTimelineEvent {
"body": string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
}
interface ReplyEvent {
"m.relates_to"?: {
"m.in_reply_to"?: {
event_id: string;
};
};
}
interface NoRelationEvent {
"m.new_content"?: never;
"m.relates_to"?: never;
}
/**
* Partial content format of timeline events with rel_type `m.replace`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#event-replacements
*/
export interface ReplacementEvent<T> {
"m.new_content": T;
"m.relates_to": {
event_id: string;
rel_type: RelationType.Replace;
};
}
/**
* Partial content format of timeline events with rel_type other than `m.replace`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#forming-relationships-between-events
*/
export interface RelationEvent {
"m.new_content"?: never;
"m.relates_to": {
event_id: string;
rel_type: Exclude<RelationType, RelationType.Replace>;
};
}
/**
* Content format of timeline events with type `m.room.message` and `msgtype` `m.text`, `m.emote`, or `m.notice`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mroommessage
*/
export interface RoomMessageTextEventContent extends BaseTimelineEvent {
msgtype: MsgType.Text | MsgType.Emote | MsgType.Notice;
format?: "org.matrix.custom.html";
formatted_body?: string;
}
/**
* Content format of timeline events with type `m.room.message` and `msgtype` `m.location`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mlocation
*/
export interface RoomMessageLocationEventContent extends BaseTimelineEvent {
body: string;
geo_uri: string;
info: Pick<FileInfo, "thumbnail_info" | "thumbnail_file" | "thumbnail_url">;
msgtype: MsgType.Location;
}
type MessageEventContent = RoomMessageTextEventContent | RoomMessageLocationEventContent | MediaEventContent;
export type RoomMessageEventContent = BaseTimelineEvent &
XOR<XOR<ReplacementEvent<MessageEventContent>, RelationEvent>, XOR<ReplyEvent, NoRelationEvent>> &
MessageEventContent;
/**
* Content format of timeline events with type `m.sticker`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#msticker
*/
export interface StickerEventContent extends BaseTimelineEvent {
body: string;
info: ImageInfo;
url: string;
}
/**
* Content format of timeline events with type `m.reaction`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mreaction
*/
export interface ReactionEventContent {
"m.relates_to": {
event_id: string;
key: string;
rel_type: RelationType.Annotation;
};
}
+24
View File
@@ -0,0 +1,24 @@
/*
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 "oidc-client-ts";
declare module "oidc-client-ts" {
interface OidcMetadata {
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
device_authorization_endpoint?: string;
}
}
-22
View File
@@ -14,20 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export interface IImageInfo {
size?: number;
mimetype?: string;
thumbnail_info?: {
// eslint-disable-line camelcase
w?: number;
h?: number;
size?: number;
mimetype?: string;
};
w?: number;
h?: number;
}
export enum Visibility {
Public = "public",
Private = "private",
@@ -59,14 +45,6 @@ export enum RestrictedAllowType {
RoomMembership = "m.room_membership",
}
export interface IJoinRuleEventContent {
join_rule: JoinRule; // eslint-disable-line camelcase
allow?: {
type: RestrictedAllowType;
room_id: string; // eslint-disable-line camelcase
}[];
}
export enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
+145
View File
@@ -0,0 +1,145 @@
/*
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 { RoomType } from "./event";
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "./partials";
import { ImageInfo } from "./media";
import { PolicyRecommendation } from "../models/invites-ignorer";
export interface RoomCanonicalAliasEventContent {
alias?: string;
alt_aliases?: string[];
}
export interface RoomCreateEventContent {
"creator"?: string;
"m.federate"?: boolean;
"predecessor"?: {
event_id: string;
room_id: string;
};
"room_version"?: string;
"type"?: RoomType;
}
export interface RoomJoinRulesEventContent {
join_rule: JoinRule;
allow?: {
room_id: string;
type: RestrictedAllowType;
}[];
}
export interface RoomMemberEventContent {
avatar_url?: string;
displayname?: string;
is_direct?: boolean;
join_authorised_via_users_server?: string;
membership: "invite" | "join" | "knock" | "leave" | "ban";
reason?: string;
third_party_invite?: {
display_name: string;
signed: {
mxid: string;
token: string;
ts: number;
};
};
}
export interface RoomThirdPartyInviteEventContent {
display_name: string;
key_validity_url: string;
public_key: string;
public_keys: {
key_validity_url?: string;
public_key: string;
}[];
}
export interface RoomPowerLevelsEventContent {
ban?: number;
events?: { [eventType: string]: number };
events_default?: number;
invite?: number;
kick?: number;
notifications?: {
room?: number;
};
redact?: number;
state_default?: number;
users?: { [userId: string]: number };
users_default?: number;
}
export interface RoomNameEventContent {
name: string;
}
export interface RoomTopicEventContent {
topic: string;
}
export interface RoomAvatarEventContent {
url?: string;
info?: ImageInfo;
}
export interface RoomPinnedEventsEventContent {
pinned: string[];
}
export interface RoomEncryptionEventContent {
algorithm: "m.megolm.v1.aes-sha2";
rotation_period_ms?: number;
rotation_period_msgs?: number;
}
export interface RoomHistoryVisibilityEventContent {
history_visibility: HistoryVisibility;
}
export interface RoomGuestAccessEventContent {
guest_access: GuestAccess;
}
export interface RoomServerAclEventContent {
allow?: string[];
allow_ip_literals?: boolean;
deny?: string[];
}
export interface RoomTombstoneEventContent {
body: string;
replacement_room: string;
}
export interface SpaceChildEventContent {
order?: string;
suggested?: boolean;
via?: string[];
}
export interface SpaceParentEventContent {
canonical?: boolean;
via?: string[];
}
export interface PolicyRuleEventContent {
entity: string;
reason: string;
recommendation: PolicyRecommendation;
}
+2 -2
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAuthDict, IAuthData } from "../interactive-auth";
import { AuthDict, IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthDict;
auth?: AuthDict;
};
/**
+116 -151
View File
@@ -35,14 +35,14 @@ import {
import { StubStore } from "./store/stub";
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
import { CallEventHandler, CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
import {
GroupCallEventHandler,
GroupCallEventHandlerEvent,
GroupCallEventHandlerEventHandlerMap,
} from "./webrtc/groupCallEventHandler";
import * as utils from "./utils";
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
import { noUnsafeEventProps, QueryDict, replaceParam, safeSet, sleep } from "./utils";
import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
@@ -64,12 +64,12 @@ import {
IdentityPrefix,
IHttpOpts,
IRequestOpts,
TokenRefreshFunction,
MatrixError,
MatrixHttpApi,
MediaPrefix,
Method,
retryNetworkOperation,
TokenRefreshFunction,
Upload,
UploadOpts,
UploadResponse,
@@ -83,7 +83,6 @@ import {
ICryptoCallbacks,
IRoomKeyRequestBody,
isCryptoAvailable,
VerificationMethod,
} from "./crypto";
import { DeviceInfo } from "./crypto/deviceinfo";
import { decodeRecoveryKey } from "./crypto/recoverykey";
@@ -145,11 +144,13 @@ import {
RelationType,
RoomCreateTypeField,
RoomType,
StateEvents,
TimelineEvents,
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "./@types/event";
import { IdServerUnbindResult, IImageInfo, JoinRule, Preset, Visibility } from "./@types/partials";
import { GuestAccess, HistoryVisibility, IdServerUnbindResult, JoinRule, Preset, Visibility } from "./@types/partials";
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
import { randomString } from "./randomstring";
import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
@@ -223,6 +224,8 @@ import { RegisterRequest, RegisterResponse } from "./@types/registration";
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
import { getRelationsThreadFilter } from "./thread-utils";
import { KnownMembership, Membership } from "./@types/membership";
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
import { ImageInfo } from "./@types/media";
export type Store = IStore;
@@ -369,8 +372,10 @@ export interface ICreateClientOpts {
* Verification methods we should offer to the other side when performing an interactive verification.
* If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS
* (aka "emojis").
*
* See {@link types.VerificationMethod} for a set of useful constants for this parameter.
*/
verificationMethods?: Array<VerificationMethod>;
verificationMethods?: Array<string>;
/**
* Whether relaying calls through a TURN server should be forced. Default false.
@@ -499,11 +504,6 @@ export interface IStartClientOpts {
*/
clientWellKnownPollPeriod?: number;
/**
* @deprecated use `threadSupport` instead
*/
experimentalThreadSupport?: boolean;
/**
* Will organises events in threaded conversations when
* a thread relation is encountered
@@ -865,10 +865,24 @@ interface IThirdPartyUser {
fields: object;
}
interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
room_type?: RoomType;
membership?: Membership;
is_encrypted: boolean;
/**
* The summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
*/
export interface RoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
/**
* The current membership of this user in the room.
* Usually "leave" if the room is fetched over federation.
*/
"membership"?: Membership;
/**
* Version of the room.
*/
"im.nheko.summary.room_version"?: string;
/**
* The encryption algorithm used for this room, if the room is encrypted.
*/
"im.nheko.summary.encryption"?: string;
}
interface IRoomKeysResponse {
@@ -1262,7 +1276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise<Room>; errorTs?: number } } = {};
protected notifTimelineSet: EventTimelineSet | null = null;
protected cryptoStore?: CryptoStore;
protected verificationMethods?: VerificationMethod[];
protected verificationMethods?: string[];
protected fallbackICEServerAllowed = false;
protected syncApi?: SlidingSyncSdk | SyncApi;
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
@@ -1437,55 +1451,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
fixNotificationCountOnDecryption(this, event);
});
// Like above, we have to listen for read receipts from ourselves in order to
// 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?.hasEncryptionStateEvent()) {
// Figure out if we've read something or if it's just informational
const content = event.getContent();
const isSelf =
Object.keys(content).filter((eid) => {
for (const [key, value] of Object.entries(content[eid])) {
if (!utils.isSupportedReceiptType(key)) continue;
if (!value) continue;
if (Object.keys(value).includes(this.getUserId()!)) return true;
}
return false;
}).length > 0;
if (!isSelf) return;
// Work backwards to determine how many events are unread. We also set
// a limit for how back we'll look to avoid spinning CPU for too long.
// If we hit the limit, we assume the count is unchanged.
const maxHistory = 20;
const events = room.getLiveTimeline().getEvents();
let highlightCount = 0;
for (let i = events.length - 1; i >= 0; i--) {
if (i === events.length - maxHistory) return; // limit reached
const event = events[i];
if (room.hasUserReadEvent(this.getUserId()!, event.getId()!)) {
// If the user has read the event, then the counting is done.
break;
}
const pushActions = this.getPushActionsForEvent(event);
highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
}
// Note: we don't need to handle 'total' notifications because the counts
// will come from the server.
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
}
});
this.ignoredInvites = new IgnoredInvites(this);
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
@@ -1575,19 +1540,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
}
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
this.logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
}
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
// We should fallback to that value for backwards compatibility purposes
if (
!this.clientOpts.hasOwnProperty("threadSupport") &&
this.clientOpts.hasOwnProperty("experimentalThreadSupport")
) {
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
}
this.syncApi.sync().catch((e) => this.logger.info("Sync startup aborted with an error:", e));
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
@@ -2817,11 +2769,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
// deprecated: use requestVerification instead
public legacyDeviceVerification(
userId: string,
deviceId: string,
method: VerificationMethod,
): Promise<VerificationRequest> {
public legacyDeviceVerification(userId: string, deviceId: string, method: string): Promise<VerificationRequest> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
@@ -4549,7 +4497,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomId - the room to update power levels in
* @param userId - the ID of the user or users to update power levels of
* @param powerLevel - the numeric power level to update given users to
* @param event - deprecated and no longer used.
* @returns Promise which resolves: to an ISendEventResponse object
* @returns Rejects: with an error response.
*/
@@ -4557,10 +4504,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
userId: string | string[],
powerLevel: number | undefined,
/**
* @deprecated no longer needed, unused.
*/
event?: MatrixEvent | null,
): Promise<ISendEventResponse> {
let content: IPowerLevelsContent | undefined;
if (this.clientRunning && this.isInitialSyncComplete()) {
@@ -4624,12 +4567,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
}
public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
public sendEvent(
public sendEvent<K extends keyof TimelineEvents>(
roomId: string,
eventType: K,
content: TimelineEvents[K],
txnId?: string,
): Promise<ISendEventResponse>;
public sendEvent<K extends keyof TimelineEvents>(
roomId: string,
threadId: string | null,
eventType: string,
content: IContent,
eventType: K,
content: TimelineEvents[K],
txnId?: string,
): Promise<ISendEventResponse>;
public sendEvent(
@@ -4928,10 +4876,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
}
path = utils.encodeUri(pathTemplate, pathParams);
} else if (event.isRedaction()) {
} else if (event.isRedaction() && event.event.redacts) {
const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
path = utils.encodeUri(pathTemplate, {
$redactsEventId: event.event.redacts!,
$redactsEventId: event.event.redacts,
...pathParams,
});
} else {
@@ -5016,27 +4964,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an ISendEventResponse object
* @returns Rejects: with an error response.
*/
public sendMessage(roomId: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
public sendMessage(roomId: string, content: RoomMessageEventContent, txnId?: string): Promise<ISendEventResponse>;
public sendMessage(
roomId: string,
threadId: string | null,
content: IContent,
content: RoomMessageEventContent,
txnId?: string,
): Promise<ISendEventResponse>;
public sendMessage(
roomId: string,
threadId: string | null | IContent,
content?: IContent | string,
threadId: string | null | RoomMessageEventContent,
content?: RoomMessageEventContent | string,
txnId?: string,
): Promise<ISendEventResponse> {
if (typeof threadId !== "string" && threadId !== null) {
txnId = content as string;
content = threadId as IContent;
content = threadId as RoomMessageEventContent;
threadId = null;
}
const eventType: string = EventType.RoomMessage;
const sendContent: IContent = content as IContent;
const eventType = EventType.RoomMessage;
const sendContent = content as RoomMessageEventContent;
return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
}
@@ -5126,33 +5074,33 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to a ISendEventResponse object
* @returns Rejects: with an error response.
*/
public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise<ISendEventResponse>;
public sendImageMessage(roomId: string, url: string, info?: ImageInfo, text?: string): Promise<ISendEventResponse>;
public sendImageMessage(
roomId: string,
threadId: string | null,
url: string,
info?: IImageInfo,
info?: ImageInfo,
text?: string,
): Promise<ISendEventResponse>;
public sendImageMessage(
roomId: string,
threadId: string | null,
url?: string | IImageInfo,
info?: IImageInfo | string,
url?: string | ImageInfo,
info?: ImageInfo | string,
text = "Image",
): Promise<ISendEventResponse> {
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
text = (info as string) || "Image";
info = url as IImageInfo;
info = url as ImageInfo;
url = threadId as string;
threadId = null;
}
const content = {
msgtype: MsgType.Image,
url: url,
info: info,
url: url as string,
info: info as ImageInfo,
body: text,
};
} satisfies RoomMessageEventContent;
return this.sendMessage(roomId, threadId, content);
}
@@ -5163,34 +5111,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public sendStickerMessage(
roomId: string,
url: string,
info?: IImageInfo,
info?: ImageInfo,
text?: string,
): Promise<ISendEventResponse>;
public sendStickerMessage(
roomId: string,
threadId: string | null,
url: string,
info?: IImageInfo,
info?: ImageInfo,
text?: string,
): Promise<ISendEventResponse>;
public sendStickerMessage(
roomId: string,
threadId: string | null,
url?: string | IImageInfo,
info?: IImageInfo | string,
url?: string | ImageInfo,
info?: ImageInfo | string,
text = "Sticker",
): Promise<ISendEventResponse> {
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
text = (info as string) || "Sticker";
info = url as IImageInfo;
info = url as ImageInfo;
url = threadId as string;
threadId = null;
}
const content = {
url: url,
info: info,
url: url as string,
info: info as ImageInfo,
body: text,
};
} satisfies StickerEventContent;
return this.sendEvent(roomId, threadId, EventType.Sticker, content);
}
@@ -5827,7 +5775,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* 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.
* are not expected. Implied `true` when `useAuthentication` is `true`.
* @param useAuthentication - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @returns the avatar URL or null.
*/
public mxcUrlToHttp(
@@ -5837,8 +5790,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
resizeMethod?: string,
allowDirectLinks?: boolean,
allowRedirects?: boolean,
useAuthentication?: boolean,
): string | null {
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects);
return getHttpUriForMxc(
this.baseUrl,
mxcUrl,
width,
height,
resizeMethod,
allowDirectLinks,
allowRedirects,
useAuthentication,
);
}
/**
@@ -6704,7 +6667,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId,
EventType.RoomGuestAccess,
{
guest_access: opts.allowJoin ? "can_join" : "forbidden",
guest_access: opts.allowJoin ? GuestAccess.CanJoin : GuestAccess.Forbidden,
},
"",
);
@@ -6715,7 +6678,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId,
EventType.RoomHistoryVisibility,
{
history_visibility: "world_readable",
history_visibility: HistoryVisibility.WorldReadable,
},
"",
);
@@ -8386,14 +8349,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Send a state event into a room
* @param roomId - ID of the room to send the event into
* @param eventType - type of the state event to send
* @param content - content of the event to send
* @param stateKey - the stateKey to send into the room
* @param opts - Options for the request function.
* @returns Promise which resolves: TODO
* @returns Rejects: with an error response.
*/
public sendStateEvent(
public sendStateEvent<K extends keyof StateEvents>(
roomId: string,
eventType: string,
content: IContent,
eventType: K,
content: StateEvents[K],
stateKey = "",
opts: IRequestOpts = {},
): Promise<ISendEventResponse> {
@@ -8406,7 +8374,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (stateKey !== undefined) {
path = utils.encodeUri(path + "/$stateKey", pathParams);
}
return this.http.authedRequest(Method.Put, path, undefined, content, opts);
return this.http.authedRequest(Method.Put, path, undefined, content as Body, opts);
}
/**
@@ -8492,11 +8460,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
since,
...options
}: IRoomDirectoryOptions = {}): Promise<IPublicRoomsResponse> {
const queryParams: QueryDict = { server, limit, since };
if (Object.keys(options).length === 0) {
const queryParams: QueryDict = { server, limit, since };
return this.http.authedRequest(Method.Get, "/publicRooms", queryParams);
} else {
return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options);
const queryParams: QueryDict = { server };
const body = {
limit,
since,
...options,
};
return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, body);
}
}
@@ -8558,17 +8532,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Get, path);
}
/**
* @returns Promise which resolves: Object with room_id and servers.
* @returns Rejects: with an error response.
* @deprecated use `getRoomIdForAlias` instead
*/
// eslint-disable-next-line camelcase
public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
return this.http.request(Method.Get, path);
}
/**
* Get the visibility of a room in the current HS's room directory
* @returns Promise which resolves: TODO
@@ -8582,7 +8545,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Set the visbility of a room in the current HS's room directory
* Set the visibility of a room in the current HS's room directory
* @param visibility - "public" to make the room visible
* in the public directory, or "private" to make
* it invisible.
@@ -9829,14 +9792,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
}
/**
* @deprecated use supportsThreads() instead
*/
public supportsExperimentalThreads(): boolean {
this.logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
return this.clientOpts?.experimentalThreadSupport || false;
}
/**
* A helper to determine thread support
* @returns a boolean to determine if threads are enabled
@@ -9860,11 +9815,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomIdOrAlias - The ID or alias of the room to get the summary of.
* @param via - The list of servers which know about the room if only an ID was provided.
*/
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return this.http.authedRequest(Method.Get, path, { via }, undefined, {
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<RoomSummary> {
const paramOpts = {
prefix: "/_matrix/client/unstable/im.nheko.summary",
});
};
try {
const path = utils.encodeUri("/summary/$roomid", { $roomid: roomIdOrAlias });
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
} else {
throw e;
}
}
}
/**
+41 -5
View File
@@ -17,7 +17,7 @@ limitations under the License.
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { IClearEvent, MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi, ImportRoomKeysOpts } from "../crypto-api";
import { CryptoApi, DecryptionFailureCode, ImportRoomKeysOpts } from "../crypto-api";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
@@ -226,10 +226,6 @@ export interface EventDecryptionResult {
* restored from backup)
*/
untrusted?: boolean;
/**
* The sender doesn't authorize the unverified devices to decrypt his messages
*/
encryptedDisabledForUnverifiedDevices?: boolean;
}
/**
@@ -263,3 +259,43 @@ export interface BackupDecryptor {
*/
free(): void;
}
/**
* Exception thrown when decryption fails
*
* @param code - Reason code for the failure.
*
* @param msg - user-visible message describing the problem
*
* @param details - key/value pairs reported in the logs but not shown
* to the user.
*/
export class DecryptionError extends Error {
public readonly detailedString: string;
public constructor(
public readonly code: DecryptionFailureCode,
msg: string,
details?: Record<string, string | Error>,
) {
super(msg);
this.name = "DecryptionError";
this.detailedString = detailedStringForDecryptionError(this, details);
}
}
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
let result = err.name + "[msg: " + err.message;
if (details) {
result +=
", " +
Object.keys(details)
.map((k) => k + ": " + details[k])
.join(", ");
}
result += "]";
return result;
}
+7 -7
View File
@@ -30,7 +30,7 @@ import {
LegacyLocationEventContent,
} from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
import { IContent } from "./models/event";
import { RoomMessageEventContent } from "./@types/events";
/**
* Generates the content for a HTML Message event
@@ -38,7 +38,7 @@ import { IContent } from "./models/event";
* @param htmlBody - the HTML representation of the message
* @returns
*/
export function makeHtmlMessage(body: string, htmlBody: string): IContent {
export function makeHtmlMessage(body: string, htmlBody: string): RoomMessageEventContent {
return {
msgtype: MsgType.Text,
format: "org.matrix.custom.html",
@@ -53,7 +53,7 @@ export function makeHtmlMessage(body: string, htmlBody: string): IContent {
* @param htmlBody - the HTML representation of the notice
* @returns
*/
export function makeHtmlNotice(body: string, htmlBody: string): IContent {
export function makeHtmlNotice(body: string, htmlBody: string): RoomMessageEventContent {
return {
msgtype: MsgType.Notice,
format: "org.matrix.custom.html",
@@ -68,7 +68,7 @@ export function makeHtmlNotice(body: string, htmlBody: string): IContent {
* @param htmlBody - the HTML representation of the emote
* @returns
*/
export function makeHtmlEmote(body: string, htmlBody: string): IContent {
export function makeHtmlEmote(body: string, htmlBody: string): RoomMessageEventContent {
return {
msgtype: MsgType.Emote,
format: "org.matrix.custom.html",
@@ -82,7 +82,7 @@ export function makeHtmlEmote(body: string, htmlBody: string): IContent {
* @param body - the plaintext body of the emote
* @returns
*/
export function makeTextMessage(body: string): IContent {
export function makeTextMessage(body: string): RoomMessageEventContent {
return {
msgtype: MsgType.Text,
body: body,
@@ -94,7 +94,7 @@ export function makeTextMessage(body: string): IContent {
* @param body - the plaintext body of the notice
* @returns
*/
export function makeNotice(body: string): IContent {
export function makeNotice(body: string): RoomMessageEventContent {
return {
msgtype: MsgType.Notice,
body: body,
@@ -106,7 +106,7 @@ export function makeNotice(body: string): IContent {
* @param body - the plaintext body of the emote
* @returns
*/
export function makeEmoteMessage(body: string): IContent {
export function makeEmoteMessage(body: string): RoomMessageEventContent {
return {
msgtype: MsgType.Emote,
body: body,
+29 -3
View File
@@ -30,7 +30,12 @@ import { encodeParams } from "./utils";
* 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.
* are not expected. Implied `true` when `useAuthentication` is `true`.
* @param useAuthentication - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @returns The complete URL to the content.
*/
export function getHttpUriForMxc(
@@ -41,6 +46,7 @@ export function getHttpUriForMxc(
resizeMethod?: string,
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -52,8 +58,23 @@ export function getHttpUriForMxc(
return "";
}
}
if (useAuthentication) {
allowRedirects = true; // per docs (MSC3916 always expects redirects)
// Dev note: MSC3916 (as of writing) removes `allow_redirect` entirely, but
// for explicitness we set it here. This makes it slightly more obvious to
// callers, hopefully.
}
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix = "/_matrix/media/v3/download/";
let prefix: string;
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/download/";
} else {
prefix = "/_matrix/media/v3/download/";
}
const params: Record<string, string> = {};
if (width) {
@@ -68,7 +89,12 @@ export function getHttpUriForMxc(
if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the
// thumbnailing API...
prefix = "/_matrix/media/v3/thumbnail/";
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/";
} else {
prefix = "/_matrix/media/v3/thumbnail/";
}
}
if (typeof allowRedirects === "boolean") {
+110
View File
@@ -496,6 +496,116 @@ export interface CryptoApi {
* @param version - The backup version to delete.
*/
deleteKeyBackupVersion(version: string): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Dehydrated devices
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Returns whether MSC3814 dehydrated devices are supported by the crypto
* backend and by the server.
*
* This should be called before calling `startDehydration`, and if this
* returns `false`, `startDehydration` should not be called.
*/
isDehydrationSupported(): Promise<boolean>;
/**
* Start using device dehydration.
*
* - Rehydrates a dehydrated device, if one is available.
* - Creates a new dehydration key, if necessary, and stores it in Secret
* Storage.
* - If `createNewKey` is set to true, always creates a new key.
* - If a dehydration key is not available, creates a new one.
* - Creates a new dehydrated device, and schedules periodically creating
* new dehydrated devices.
*
* This function must not be called unless `isDehydrationSupported` returns
* `true`, and must not be called until after cross-signing and secret
* storage have been set up.
*
* @param createNewKey - whether to force creation of a new dehydration key.
* This can be used, for example, if Secret Storage is being reset. Defaults
* to false.
*/
startDehydration(createNewKey?: boolean): Promise<void>;
}
/** A reason code for a failure to decrypt an event. */
export enum DecryptionFailureCode {
/** Message was encrypted with a Megolm session whose keys have not been shared with us. */
MEGOLM_UNKNOWN_INBOUND_SESSION_ID = "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
/** Message was encrypted with a Megolm session which has been shared with us, but in a later ratchet state. */
OLM_UNKNOWN_MESSAGE_INDEX = "OLM_UNKNOWN_MESSAGE_INDEX",
/**
* Message was sent before the current device was created; there is no key backup on the server, so this
* decryption failure is expected.
*/
HISTORICAL_MESSAGE_NO_KEY_BACKUP = "HISTORICAL_MESSAGE_NO_KEY_BACKUP",
/**
* Message was sent before the current device was created; there was a key backup on the server, but we don't
* seem to have access to the backup. (Probably we don't have the right key.)
*/
HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED = "HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED",
/**
* Message was sent before the current device was created; there was a (usable) key backup on the server, but we
* still can't decrypt. (Either the session isn't in the backup, or we just haven't gotten around to checking yet.)
*/
HISTORICAL_MESSAGE_WORKING_BACKUP = "HISTORICAL_MESSAGE_WORKING_BACKUP",
/**
* Message was sent when the user was not a member of the room.
*/
HISTORICAL_MESSAGE_USER_NOT_JOINED = "HISTORICAL_MESSAGE_USER_NOT_JOINED",
/** Unknown or unclassified error. */
UNKNOWN_ERROR = "UNKNOWN_ERROR",
/** @deprecated only used in legacy crypto */
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
/** @deprecated only used in legacy crypto */
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
/** @deprecated only used in legacy crypto */
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
/** @deprecated only used in legacy crypto */
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
/** @deprecated only used in legacy crypto */
OLM_BAD_ROOM = "OLM_BAD_ROOM",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
/** @deprecated only used in legacy crypto */
OLM_BAD_SENDER = "OLM_BAD_SENDER",
/** @deprecated only used in legacy crypto */
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
/** @deprecated only used in legacy crypto */
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
/** @deprecated only used in legacy crypto */
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
/** @deprecated only used in legacy crypto */
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
}
/**
+6 -5
View File
@@ -18,11 +18,12 @@ import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility }
import { logger, Logger } from "../logger";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import * as algorithms from "./algorithms";
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
import { IMessage } from "./algorithms/olm";
import { DecryptionFailureCode } from "../crypto-api";
import { DecryptionError } from "../common-crypto/CryptoBackend";
// The maximum size of an event is 65K, and we base64 the content, so this is a
// reasonable approximation to the biggest plaintext we can encrypt.
@@ -1220,8 +1221,8 @@ export class OlmDevice {
this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => {
if (session === null || sessionData === null) {
if (withheld) {
error = new algorithms.DecryptionError(
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
error = new DecryptionError(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
calculateWithheldMessage(withheld),
{
session: senderKey + "|" + sessionId,
@@ -1236,8 +1237,8 @@ export class OlmDevice {
res = session.decrypt(body);
} catch (e) {
if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) {
error = new algorithms.DecryptionError(
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
error = new DecryptionError(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
calculateWithheldMessage(withheld),
{
session: senderKey + "|" + sessionId,
+3 -39
View File
@@ -199,45 +199,6 @@ export abstract class DecryptionAlgorithm {
public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
}
/**
* Exception thrown when decryption fails
*
* @param msg - user-visible message describing the problem
*
* @param details - key/value pairs reported in the logs but not shown
* to the user.
*/
export class DecryptionError extends Error {
public readonly detailedString: string;
public constructor(
public readonly code: string,
msg: string,
details?: Record<string, string | Error>,
) {
super(msg);
this.code = code;
this.name = "DecryptionError";
this.detailedString = detailedStringForDecryptionError(this, details);
}
}
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
let result = err.name + "[msg: " + err.message;
if (details) {
result +=
", " +
Object.keys(details)
.map((k) => k + ": " + details[k])
.join(", ");
}
result += "]";
return result;
}
export class UnknownDeviceError extends Error {
/**
* Exception thrown specifically when we want to warn the user to consider
@@ -274,3 +235,6 @@ export function registerAlgorithm<P extends IParams = IParams>(
ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm);
DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm);
}
/* Re-export for backwards compatibility. Deprecated: this is an internal class. */
export { DecryptionError } from "../../common-crypto/CryptoBackend";
+11 -7
View File
@@ -26,7 +26,6 @@ import * as olmlib from "../olmlib";
import {
DecryptionAlgorithm,
DecryptionClassParams,
DecryptionError,
EncryptionAlgorithm,
IParams,
registerAlgorithm,
@@ -45,6 +44,8 @@ import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api";
import { immediate, MapWithDefault } from "../../utils";
import { KnownMembership } from "../../@types/membership";
import { DecryptionFailureCode } from "../../crypto-api";
import { DecryptionError } from "../../common-crypto/CryptoBackend";
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@@ -1313,7 +1314,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
const content = event.getWireContent();
if (!content.sender_key || !content.session_id || !content.ciphertext) {
throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input");
throw new DecryptionError(DecryptionFailureCode.MEGOLM_MISSING_FIELDS, "Missing fields in input");
}
// we add the event to the pending list *before* we start decryption.
@@ -1339,12 +1340,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
throw e;
}
let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
let errorCode = DecryptionFailureCode.OLM_DECRYPT_GROUP_MESSAGE_ERROR;
if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") {
this.requestKeysForEvent(event);
errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
errorCode = DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX;
}
throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
@@ -1377,13 +1378,13 @@ export class MegolmDecryption extends DecryptionAlgorithm {
if (problem.fixed) {
problemDescription += " Trying to create a new secure channel and re-requesting the keys.";
}
throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, {
throw new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, problemDescription, {
session: content.sender_key + "|" + content.session_id,
});
}
throw new DecryptionError(
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
"The sender's device has not sent us the keys for this message.",
{
session: content.sender_key + "|" + content.session_id,
@@ -1405,7 +1406,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// (this is somewhat redundant, since the megolm session is scoped to the
// room, so neither the sender nor a MITM can lie about the room_id).
if (payload.room_id !== event.getRoomId()) {
throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id);
throw new DecryptionError(
DecryptionFailureCode.MEGOLM_BAD_ROOM,
"Message intended for room " + payload.room_id,
);
}
return {
+50 -22
View File
@@ -22,11 +22,13 @@ import type { IEventDecryptionResult } from "../../@types/crypto";
import { logger } from "../../logger";
import * as olmlib from "../olmlib";
import { DeviceInfo } from "../deviceinfo";
import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base";
import { DecryptionAlgorithm, EncryptionAlgorithm, registerAlgorithm } from "./base";
import { Room } from "../../models/room";
import { IContent, MatrixEvent } from "../../models/event";
import { IEncryptedContent, IOlmEncryptedContent } from "../index";
import { IInboundSession } from "../OlmDevice";
import { DecryptionFailureCode } from "../../crypto-api";
import { DecryptionError } from "../../common-crypto/CryptoBackend";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -159,11 +161,14 @@ class OlmDecryption extends DecryptionAlgorithm {
const ciphertext = content.ciphertext;
if (!ciphertext) {
throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
throw new DecryptionError(DecryptionFailureCode.OLM_MISSING_CIPHERTEXT, "Missing ciphertext");
}
if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) {
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
throw new DecryptionError(
DecryptionFailureCode.OLM_NOT_INCLUDED_IN_RECIPIENTS,
"Not included in recipients",
);
}
const message = ciphertext[this.olmDevice.deviceCurve25519Key!];
let payloadString: string;
@@ -171,7 +176,7 @@ class OlmDecryption extends DecryptionAlgorithm {
try {
payloadString = await this.decryptMessage(deviceKey, message);
} catch (e) {
throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
throw new DecryptionError(DecryptionFailureCode.OLM_BAD_ENCRYPTED_MESSAGE, "Bad Encrypted Message", {
sender: deviceKey,
err: e as Error,
});
@@ -182,14 +187,21 @@ class OlmDecryption extends DecryptionAlgorithm {
// check that we were the intended recipient, to avoid unknown-key attack
// https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient != this.userId) {
throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_RECIPIENT,
"Message was intended for " + payload.recipient,
);
}
if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", {
intended: payload.recipient_keys.ed25519,
our_key: this.olmDevice.deviceEd25519Key!,
});
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_RECIPIENT_KEY,
"Message not intended for this device",
{
intended: payload.recipient_keys.ed25519,
our_key: this.olmDevice.deviceEd25519Key!,
},
);
}
// check that the device that encrypted the event belongs to the user that the event claims it's from.
@@ -216,18 +228,26 @@ class OlmDecryption extends DecryptionAlgorithm {
try {
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
} catch (e) {
throw new DecryptionError("OLM_BAD_SENDER_CHECK_FAILED", "Could not verify sender identity", {
sender: deviceKey,
err: e as Error,
});
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_SENDER_CHECK_FAILED,
"Could not verify sender identity",
{
sender: deviceKey,
err: e as Error,
},
);
}
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
}
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
real_sender: senderKeyUser,
});
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_SENDER,
"Message claimed to be from " + event.getSender(),
{
real_sender: senderKeyUser,
},
);
}
// check that the original sender matches what the homeserver told us, to
@@ -235,16 +255,24 @@ class OlmDecryption extends DecryptionAlgorithm {
// (this check is also provided via the sender's embedded ed25519 key,
// which is checked elsewhere).
if (payload.sender != event.getSender()) {
throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, {
reported_sender: event.getSender()!,
});
throw new DecryptionError(
DecryptionFailureCode.OLM_FORWARDED_MESSAGE,
"Message forwarded from " + payload.sender,
{
reported_sender: event.getSender()!,
},
);
}
// Olm events intended for a room have a room_id.
if (payload.room_id !== event.getRoomId()) {
throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, {
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
});
throw new DecryptionError(
DecryptionFailureCode.OLM_BAD_ROOM,
"Message intended for room " + payload.room_id,
{
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
},
);
}
const claimedKeys = payload.keys || {};
+3
View File
@@ -871,6 +871,9 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Aes256.algorithmName]: Aes256,
};
// the linter doesn't like this but knip does
// eslint-disable-next-line tsdoc/syntax
/** @alias */
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
/**
+3 -3
View File
@@ -16,9 +16,9 @@ limitations under the License.
import { logger } from "../logger";
export let crypto = globalThis.window?.crypto;
export let subtleCrypto = globalThis.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
export let TextEncoder = globalThis.window?.TextEncoder;
export let crypto = globalThis.crypto;
export let subtleCrypto = crypto?.subtle ?? crypto?.webkitSubtle; // TODO: Stop using webkitSubtle fallback
export let TextEncoder = globalThis.TextEncoder;
/* eslint-disable @typescript-eslint/no-var-requires */
if (!crypto) {
+20 -4
View File
@@ -73,7 +73,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { ISignatures } from "../@types/signed";
import { IMessage } from "./algorithms/olm";
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { MapWithDefault, recursiveMapToObject } from "../utils";
import {
@@ -90,6 +90,7 @@ import {
BackupTrustInfo,
BootstrapCrossSigningOpts,
CrossSigningStatus,
DecryptionFailureCode,
DeviceVerificationStatus,
EventEncryptionInfo,
EventShieldColour,
@@ -97,8 +98,8 @@ import {
ImportRoomKeysOpts,
KeyBackupCheck,
KeyBackupInfo,
VerificationRequest as CryptoApiVerificationRequest,
OwnDeviceKeys,
VerificationRequest as CryptoApiVerificationRequest,
} from "../crypto-api";
import { Device, DeviceMap } from "../models/device";
import { deviceInfoToDevice } from "./device-converter";
@@ -4209,8 +4210,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
if (!AlgClass) {
throw new algorithms.DecryptionError(
"UNKNOWN_ENCRYPTION_ALGORITHM",
throw new DecryptionError(
DecryptionFailureCode.UNKNOWN_ENCRYPTION_ALGORITHM,
'Unknown encryption algorithm "' + algorithm + '".',
);
}
@@ -4286,6 +4287,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public getRoomEncryption(roomId: string): IRoomEncryption | null {
return this.roomList.getRoomEncryption(roomId);
}
/**
* Returns whether dehydrated devices are supported by the crypto backend
* and by the server.
*/
public async isDehydrationSupported(): Promise<boolean> {
return false;
}
/**
* Stub function -- dehydration is not implemented here, so throw error
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
throw new Error("Not implemented");
}
}
/**
+22 -18
View File
@@ -37,6 +37,17 @@ import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
return encodeURIComponent(senderCurve25519Key) + "/" + encodeURIComponent(sessionId);
}
function decodeSessionKey(key: string): { senderKey: string; sessionId: string } {
const keyParts = key.split("/");
const senderKey = decodeURIComponent(keyParts[0]);
const sessionId = decodeURIComponent(keyParts[1]);
return { senderKey, sessionId };
}
/**
* Internal module. in-memory storage for e2e.
*/
@@ -481,20 +492,14 @@ export class MemoryCryptoStore implements CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (const key of Object.keys(this.inboundGroupSessions)) {
// 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).
func({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
...decodeSessionKey(key),
sessionData: this.inboundGroupSessions[key],
});
}
@@ -507,7 +512,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
if (this.inboundGroupSessions[k] === undefined) {
this.inboundGroupSessions[k] = sessionData;
}
@@ -519,7 +524,8 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessions[k] = sessionData;
}
public storeEndToEndInboundGroupSessionWithheld(
@@ -528,7 +534,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: IWithheld,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessionsWithheld[k] = sessionData;
}
@@ -554,8 +560,7 @@ export class MemoryCryptoStore implements CryptoStore {
const result: SessionExtended[] = [];
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
result.push({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
...decodeSessionKey(key),
sessionData: session,
needsBackup: key in this.sessionsNeedingBackup,
});
@@ -584,7 +589,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = senderKey + "/" + sessionId;
const k = encodeSessionKey(senderKey, sessionId);
delete this.inboundGroupSessions[k];
}
}
@@ -614,8 +619,7 @@ export class MemoryCryptoStore implements CryptoStore {
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
...decodeSessionKey(session),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
@@ -632,7 +636,7 @@ export class MemoryCryptoStore implements CryptoStore {
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
delete this.sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
@@ -640,7 +644,7 @@ export class MemoryCryptoStore implements CryptoStore {
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
this.sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
+4 -3
View File
@@ -28,9 +28,10 @@ import { MatrixClient } from "../../client";
import { IVerificationChannel } from "./request/Channel";
import { MatrixEvent } from "../../models/event";
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
import { VerificationMethod } from "../../types";
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
export const SHOW_QR_CODE_METHOD = VerificationMethod.ShowQrCode;
export const SCAN_QR_CODE_METHOD = VerificationMethod.ScanQrCode;
/** @deprecated use VerifierEvent */
export type QrCodeEvent = VerifierEvent;
@@ -73,7 +74,7 @@ export class ReciprocateQRCode extends Base {
await new Promise<void>((resolve, reject) => {
this.reciprocateQREvent = {
confirm: resolve,
cancel: () => reject(newUserCancelledError()),
cancel: (): void => reject(newUserCancelledError()),
};
this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
});
+4 -3
View File
@@ -34,6 +34,7 @@ import { IContent, MatrixEvent } from "../../models/event";
import { generateDecimalSas } from "./SASDecimal";
import { EventType } from "../../@types/event";
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
import { VerificationMethod } from "../../types";
// backwards-compatibility exports
export type {
@@ -233,7 +234,7 @@ export class SAS extends Base {
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
return "m.sas.v1";
return VerificationMethod.Sas;
}
public get events(): string[] {
@@ -307,8 +308,8 @@ export class SAS extends Base {
reject(err);
}
},
cancel: () => reject(newUserCancelledError()),
mismatch: () => reject(newMismatchedSASError()),
cancel: (): void => reject(newUserCancelledError()),
mismatch: (): void => reject(newMismatchedSASError()),
};
this.emit(SasEvent.ShowSas, this.sasEvent);
});
@@ -18,7 +18,7 @@ limitations under the License.
import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest";
import { logger } from "../../../logger";
import { IVerificationChannel } from "./Channel";
import { EventType } from "../../../@types/event";
import { EventType, TimelineEvents } from "../../../@types/event";
import { MatrixClient } from "../../../client";
import { MatrixEvent } from "../../../models/event";
import { IRequestsMap } from "../..";
@@ -299,7 +299,11 @@ export class InRoomChannel implements IVerificationChannel {
if (type === REQUEST_TYPE) {
sendType = MESSAGE_TYPE;
}
const response = await this.client.sendEvent(this.roomId, sendType, content);
const response = await this.client.sendEvent(
this.roomId,
sendType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
if (type === REQUEST_TYPE) {
this.requestEventId = response.event_id;
}
-19
View File
@@ -14,25 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export enum InvalidStoreState {
ToggledLazyLoading,
}
export class InvalidStoreError extends Error {
public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
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`;
super(message);
this.name = "InvalidStoreError";
}
}
export enum InvalidCryptoStoreState {
TooNew = "TOO_NEW",
}
+3
View File
@@ -19,4 +19,7 @@ export enum Method {
Put = "PUT",
Post = "POST",
Delete = "DELETE",
Options = "OPTIONS",
Head = "HEAD",
Patch = "PATCH",
}
+5 -11
View File
@@ -140,12 +140,6 @@ export type AuthDict =
| { type: Exclude<string, AuthType>; [key: string]: any }
| {};
/**
* Backwards compatible export
* @deprecated in favour of AuthDict
*/
export type IAuthDict = AuthDict;
export class NoAuthFlowFoundError extends Error {
public name = "NoAuthFlowFoundError";
@@ -168,7 +162,7 @@ export class NoAuthFlowFoundError extends Error {
*
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
*/
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
export type UIAuthCallback<T> = (makeRequest: (authData: AuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
interface IOpts<T> {
/**
@@ -340,7 +334,7 @@ export class InteractiveAuth<T> {
// another just to check what the status is
if (this.submitPromise) return;
let authDict: IAuthDict = {};
let authDict: AuthDict = {};
if (this.currentStage == EMAIL_STAGE_TYPE) {
// The email can be validated out-of-band, but we need to provide the
// creds so the HS can go & check it.
@@ -410,7 +404,7 @@ export class InteractiveAuth<T> {
* in the attemptAuth promise being rejected. This can be set to true
* for requests that just poll to see if auth has been completed elsewhere.
*/
public async submitAuthDict(authData: IAuthDict, background = false): Promise<void> {
public async submitAuthDict(authData: AuthDict, background = false): Promise<void> {
if (!this.attemptAuthDeferred) {
throw new Error("submitAuthDict() called before attemptAuth()");
}
@@ -431,7 +425,7 @@ export class InteractiveAuth<T> {
}
// use the sessionid from the last request, if one is present.
let auth: IAuthDict;
let auth: AuthDict;
if ((this.data as IAuthData)?.session) {
auth = {
session: (this.data as IAuthData).session,
@@ -515,7 +509,7 @@ export class InteractiveAuth<T> {
* This can be set to true for requests that just poll to see if auth has
* been completed elsewhere.
*/
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
private async doRequest(auth: AuthDict | null, background = false): Promise<void> {
try {
const result = await this.requestCallback(auth, background);
this.attemptAuthDeferred!.resolve(result);
+5
View File
@@ -77,6 +77,7 @@ export * from "./@types/extensible_events";
export * from "./@types/IIdentityServerProvider";
export * from "./models/room-summary";
export * from "./models/event-status";
export type { RoomSummary } from "./client";
export * as ContentHelpers from "./content-helpers";
export * as SecretStorage from "./secret-storage";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
@@ -104,6 +105,10 @@ export { IdentityProviderBrand, SSOAction } from "./@types/auth";
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces";
export { LocationAssetType } from "./@types/location";
/**
* @deprecated in favour of RoomJoinRulesEventContent on the types export
*/
export type { RoomJoinRulesEventContent as IJoinRuleEventContent } from "./@types/state_events";
/**
* Types supporting cryptography.
-1
View File
@@ -136,7 +136,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
if (eventMemberships === undefined) {
logger.debug(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
continue;
}
if (!Array.isArray(eventMemberships)) {
+18 -3
View File
@@ -15,12 +15,27 @@ limitations under the License.
*/
import { MatrixClient } from "../client";
import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
import { RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
import { IContent, MatrixEvent } from "./event";
import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
import { EventTimeline } from "./event-timeline";
import { FileType } from "../http-api";
import type { ISendEventResponse } from "../@types/requests";
import { EncryptedFile } from "../@types/media";
export interface MSC3089EventContent {
active?: boolean;
name?: string;
locked?: boolean;
version?: number;
}
export interface MSC3089EventContent {
active?: boolean;
name?: string;
locked?: boolean;
version?: number;
}
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
@@ -131,7 +146,7 @@ export class MSC3089Branch {
* Gets information about the file needed to download it.
* @returns Information about the file.
*/
public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> {
public async getFileInfo(): Promise<{ info: EncryptedFile; httpUrl: string }> {
const event = await this.getFileEvent();
const file = event.getOriginalContent()["file"];
@@ -179,7 +194,7 @@ export class MSC3089Branch {
public async createNewVersion(
name: string,
encryptedContents: FileType,
info: Partial<IEncryptedFile>,
info: EncryptedFile,
additionalContent?: IContent,
): Promise<ISendEventResponse> {
const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, {
+19 -9
View File
@@ -17,7 +17,7 @@ limitations under the License.
import promiseRetry from "p-retry";
import { MatrixClient } from "../client";
import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
import { Room } from "./room";
import { logger } from "../logger";
import { IContent, MatrixEvent } from "./event";
@@ -34,6 +34,8 @@ import { isRoomSharedHistory } from "../crypto/algorithms/megolm";
import { ISendEventResponse } from "../@types/requests";
import { FileType } from "../http-api";
import { KnownMembership } from "../@types/membership";
import { RoomPowerLevelsEventContent, SpaceChildEventContent } from "../@types/state_events";
import { EncryptedFile, FileContent } from "../@types/media";
/**
* The recommended defaults for a tree space's power levels. Note that this
@@ -78,6 +80,12 @@ export enum TreePermissions {
Owner = "owner", // "Admin" or PL100
}
declare module "../@types/media" {
interface FileContent {
[UNSTABLE_MSC3089_LEAF.name]?: {};
}
}
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
* file tree Space. Note that this is UNSTABLE and subject to breaking changes
@@ -176,7 +184,7 @@ export class MSC3089TreeSpace {
const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
const pls = currentPls?.getContent() || {};
const pls = currentPls?.getContent<RoomPowerLevelsEventContent>() || {};
const viewLevel = pls["users_default"] || 0;
const editLevel = pls["events_default"] || 50;
const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100;
@@ -234,7 +242,7 @@ export class MSC3089TreeSpace {
this.roomId,
EventType.SpaceChild,
{
via: [this.client.getDomain()],
via: [this.client.getDomain()!],
},
directory.roomId,
);
@@ -243,7 +251,7 @@ export class MSC3089TreeSpace {
directory.roomId,
EventType.SpaceParent,
{
via: [this.client.getDomain()],
via: [this.client.getDomain()!],
},
this.roomId,
);
@@ -450,7 +458,9 @@ export class MSC3089TreeSpace {
// XXX: We should be creating gaps to avoid conflicts
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
const content = currentChild?.getContent<SpaceChildEventContent>() ?? {
via: [this.client.getDomain()!],
};
await this.client.sendStateEvent(
parentRoom.roomId,
EventType.SpaceChild,
@@ -473,7 +483,7 @@ export class MSC3089TreeSpace {
// Now we can finally update our own order state
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
const content = currentChild?.getContent<SpaceChildEventContent>() ?? { via: [this.client.getDomain()!] };
await this.client.sendStateEvent(
parentRoom.roomId,
EventType.SpaceChild,
@@ -499,7 +509,7 @@ export class MSC3089TreeSpace {
public async createFile(
name: string,
encryptedContents: FileType,
info: Partial<IEncryptedFile>,
info: EncryptedFile,
additionalContent?: IContent,
): Promise<ISendEventResponse> {
const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, {
@@ -507,7 +517,7 @@ export class MSC3089TreeSpace {
});
info.url = mxc;
const fileContent = {
const fileContent: FileContent = {
msgtype: MsgType.File,
body: name,
url: mxc,
@@ -526,7 +536,7 @@ export class MSC3089TreeSpace {
...additionalContent,
...fileContent,
[UNSTABLE_MSC3089_LEAF.name]: {},
});
} as FileContent);
await this.client.sendStateEvent(
this.roomId,
+4
View File
@@ -51,6 +51,9 @@ export class Device {
/** display name of the device */
public readonly displayName?: string;
/** whether the device is a dehydrated device */
public readonly dehydrated: boolean = false;
public constructor(opts: DeviceParameters) {
this.deviceId = opts.deviceId;
this.userId = opts.userId;
@@ -59,6 +62,7 @@ export class Device {
this.verified = opts.verified || DeviceVerification.Unverified;
this.signatures = opts.signatures || new Map();
this.displayName = opts.displayName;
this.dehydrated = !!opts.dehydrated;
}
/**
+73 -69
View File
@@ -31,6 +31,7 @@ import {
RelationType,
ToDeviceMessageId,
UNSIGNED_THREAD_ID_FIELD,
UNSIGNED_MEMBERSHIP_FIELD,
} from "../@types/event";
import { Crypto } from "../crypto";
import { deepSortedObjectEntries, internaliseString } from "../utils";
@@ -41,13 +42,13 @@ import { TypedReEmitter } from "../ReEmitter";
import { MatrixError } from "../http-api";
import { TypedEventEmitter } from "./typed-event-emitter";
import { EventStatus } from "./event-status";
import { DecryptionError } from "../crypto/algorithms";
import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { CryptoBackend, DecryptionError } from "../common-crypto/CryptoBackend";
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
import { IAnnotatedPushRule } from "../@types/PushRules";
import { Room } from "./room";
import { EventTimeline } from "./event-timeline";
import { Membership } from "../@types/membership";
import { DecryptionFailureCode } from "../crypto-api";
export { EventStatus } from "./event-status";
@@ -76,6 +77,7 @@ export interface IUnsigned {
"invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
[UNSIGNED_MEMBERSHIP_FIELD.name]?: Membership | string;
}
export interface IThreadBundledRelationship {
@@ -227,7 +229,18 @@ export interface IMessageVisibilityHidden {
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
export enum MatrixEventEvent {
/**
* An event has been decrypted, or we have failed to decrypt it.
*
* The payload consists of:
*
* * `event` - The {@link MatrixEvent} which we attempted to decrypt.
*
* * `err` - The error that occurred during decryption, or `undefined` if no error occurred.
* Avoid use of this: {@link MatrixEvent.decryptionFailureReason} is more useful.
*/
Decrypted = "Event.decrypted",
BeforeRedaction = "Event.beforeRedaction",
VisibilityChange = "Event.visibilityChange",
LocalEventIdReplaced = "Event.localEventIdReplaced",
@@ -239,12 +252,6 @@ export enum MatrixEventEvent {
export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update;
export type MatrixEventHandlerMap = {
/**
* Fires when an event is decrypted
*
* @param event - The matrix event which has been decrypted
* @param err - The error that occurred during decryption, or `undefined` if no error occurred.
*/
[MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void;
[MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void;
[MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void;
@@ -274,6 +281,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
private _hasCachedExtEv = false;
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
private _decryptionFailureReason: DecryptionFailureCode | null = null;
/* curve25519 key which we believe belongs to the sender of the event. See
* getSenderKey()
*/
@@ -713,6 +723,22 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.event.state_key !== undefined;
}
/**
* Get the user's room membership at the time the event was sent, as reported
* by the server. This uses MSC4115.
*
* @returns The user's room membership, or `undefined` if the server does
* not report it.
*/
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
if (typeof unsigned[UNSIGNED_MEMBERSHIP_FIELD.name] === "string") {
return unsigned[UNSIGNED_MEMBERSHIP_FIELD.name];
} else {
return undefined;
}
}
/**
* Replace the content of this event with encrypted versions.
* (This is used when sending an event; it should not be used by applications).
@@ -771,7 +797,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* couldn't decrypt.
*/
public isDecryptionFailure(): boolean {
return this.clearEvent?.content?.msgtype === "m.bad.encrypted";
return this._decryptionFailureReason !== null;
}
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
public get decryptionFailureReason(): DecryptionFailureCode | null {
return this._decryptionFailureReason;
}
/*
@@ -884,17 +915,14 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
while (true) {
this.retryDecryption = false;
let res: IEventDecryptionResult;
let err: Error | undefined = undefined;
try {
if (!crypto) {
res = this.badEncryptedMessage("Encryption not enabled");
} else {
res = await crypto.decryptEvent(this);
if (options.isRetry === true) {
logger.info(`Decrypted event on retry (${this.getDetails()})`);
}
const res = await crypto.decryptEvent(this);
if (options.isRetry === true) {
logger.info(`Decrypted event on retry (${this.getDetails()})`);
}
this.setClearData(res);
this._decryptionFailureReason = null;
} catch (e) {
const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
@@ -927,14 +955,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// so we don't bother to log `e` separately.
logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
res = this.badEncryptedMessage(String(e));
this.setClearDataForDecryptionFailure(String(e));
this._decryptionFailureReason =
e instanceof DecryptionError ? (<DecryptionError>e).code : DecryptionFailureCode.UNKNOWN_ERROR;
}
// at this point, we've either successfully decrypted the event, or have given up
// (and set res to a 'badEncryptedMessage'). Either way, we can now set the
// cleartext of the event and raise Event.decrypted.
//
// make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
// Make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
// otherwise the app will be confused to see `isBeingDecrypted` still set when
// there isn't an `Event.decrypted` on the way.
//
@@ -942,7 +968,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
//
this.decryptionPromise = null;
this.retryDecryption = false;
this.setClearData(res);
// Before we emit the event, clear the push actions so that they can be recalculated
// by relevant code. We do this because the clear event has now changed, making it
@@ -960,19 +985,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
}
private badEncryptedMessage(reason: string): IEventDecryptionResult {
return {
clearEvent: {
type: EventType.RoomMessage,
content: {
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: " + reason + " **",
},
},
encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`,
};
}
/**
* Update the cleartext data on this event.
*
@@ -981,9 +993,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @internal
*
* @param decryptionResult - the decryption result, including the plaintext and some key info
*
* @remarks
* Fires {@link MatrixEventEvent.Decrypted}
*/
private setClearData(decryptionResult: IEventDecryptionResult): void {
this.clearEvent = decryptionResult.clearEvent;
@@ -991,7 +1000,28 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
this.untrusted = decryptionResult.untrusted || false;
this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false;
this.encryptedDisabledForUnverifiedDevices = false;
this.invalidateExtensibleEvent();
}
/**
* Update the cleartext data on this event after a decryption failure.
*
* @param reason - the textual reason for the failure
*/
private setClearDataForDecryptionFailure(reason: string): void {
this.clearEvent = {
type: EventType.RoomMessage,
content: {
msgtype: "m.bad.encrypted",
body: `** Unable to decrypt: ${reason} **`,
},
};
this.senderCurve25519Key = null;
this.claimedEd25519Key = null;
this.forwardingCurve25519KeyChain = [];
this.untrusted = false;
this.encryptedDisabledForUnverifiedDevices = reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`;
this.invalidateExtensibleEvent();
}
@@ -1160,10 +1190,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.visibility;
}
/**
* @deprecated In favor of the overload that includes a Room argument
*/
public makeRedacted(redactionEvent: MatrixEvent): void;
/**
* Update the content of an event in the same way it would be by the server
* if it were redacted before it was sent to us
@@ -1171,8 +1197,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @param redactionEvent - event causing the redaction
* @param room - the room in which the event exists
*/
public makeRedacted(redactionEvent: MatrixEvent, room: Room): void;
public makeRedacted(redactionEvent: MatrixEvent, room?: Room): void {
public makeRedacted(redactionEvent: MatrixEvent, room: Room): void {
// quick sanity-check
if (!redactionEvent.event) {
throw new Error("invalid redactionEvent in makeRedacted");
@@ -1218,7 +1243,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// If the redacted event was in a thread (but not thread root), move it
// to the main timeline. This will change if MSC3389 is merged.
if (room && !this.isThreadRoot && this.threadRootId && this.threadRootId !== this.getId()) {
if (!this.isThreadRoot && this.threadRootId && this.threadRootId !== this.getId()) {
this.moveAllRelatedToMainTimeline(room);
redactionEvent.moveToMainTimeline(room);
}
@@ -1355,19 +1380,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.pushDetails;
}
/**
* Set the push actions for this event.
* Clears rule from push details if present
* @deprecated use `setPushDetails`
*
* @param pushActions - push actions
*/
public setPushActions(pushActions: IActionsObject | null): void {
this.pushDetails = {
actions: pushActions || undefined,
};
}
/**
* Set the push details for this event.
*
@@ -1568,14 +1580,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
}
/**
* Checks if this event is associated with another event. See `getAssociatedId`.
* @deprecated use hasAssociation instead.
*/
public hasAssocation(): boolean {
return !!this.getAssociatedId();
}
/**
* Checks if this event is associated with another event. See `getAssociatedId`.
*/
+10 -3
View File
@@ -22,6 +22,7 @@ import { EventTimeline } from "./event-timeline";
import { Preset } from "../@types/partials";
import { globToRegexp } from "../utils";
import { Room } from "./room";
import { EventType, StateEvents } from "../@types/event";
/// The event type storing the user's individual policies.
///
@@ -37,7 +38,7 @@ export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue(
);
/// The types of recommendations understood.
enum PolicyRecommendation {
export enum PolicyRecommendation {
Ban = "m.ban",
}
@@ -64,6 +65,12 @@ export enum PolicyScope {
Server = "m.policy.server",
}
const scopeToEventTypeMap: Record<PolicyScope, keyof StateEvents> = {
[PolicyScope.User]: EventType.PolicyRuleUser,
[PolicyScope.Room]: EventType.PolicyRuleRoom,
[PolicyScope.Server]: EventType.PolicyRuleServer,
};
/**
* A container for ignored invites.
*
@@ -87,7 +94,7 @@ export class IgnoredInvites {
*/
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
const target = await this.getOrCreateTargetRoom();
const response = await this.client.sendStateEvent(target.roomId, scope, {
const response = await this.client.sendStateEvent(target.roomId, scopeToEventTypeMap[scope], {
entity,
reason,
recommendation: PolicyRecommendation.Ban,
@@ -173,7 +180,7 @@ export class IgnoredInvites {
{ scope: PolicyScope.User, entities: [sender] },
{ scope: PolicyScope.Server, entities: [senderServer, roomServer] },
]) {
const events = state.getStateEvents(scope);
const events = state.getStateEvents(scopeToEventTypeMap[scope]);
for (const event of events) {
const content = event.getContent();
if (content?.recommendation != PolicyRecommendation.Ban) {
+13 -3
View File
@@ -20,12 +20,13 @@ import { isNumber, removeHiddenChars } from "../utils";
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client";
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
import { GuestAccess, HistoryVisibility, JoinRule } from "../@types/partials";
import { TypedEventEmitter } from "./typed-event-emitter";
import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon";
import { TypedReEmitter } from "../ReEmitter";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
import { KnownMembership } from "../@types/membership";
import { RoomJoinRulesEventContent } from "../@types/state_events";
export interface IMarkerFoundOptions {
/** Whether the timeline was empty before the marker event arrived in the
@@ -53,6 +54,7 @@ enum OobStatus {
export interface IPowerLevelsContent {
users?: Record<string, number>;
events?: Record<string, number>;
notifications?: Partial<Record<"room", number>>;
// eslint-disable-next-line camelcase
users_default?: number;
// eslint-disable-next-line camelcase
@@ -60,6 +62,7 @@ export interface IPowerLevelsContent {
// eslint-disable-next-line camelcase
state_default?: number;
ban?: number;
invite?: number;
kick?: number;
redact?: number;
}
@@ -76,6 +79,8 @@ export enum RoomStateEvent {
export type RoomStateEventHandlerMap = {
/**
* Fires whenever the event dictionary in room state is updated.
* This does not guarantee that any related objects (like RoomMember) have been updated.
* Use RoomStateEvent.Update for that.
* @param event - The matrix event which caused this event to fire.
* @param state - The room state whose RoomState.events dictionary
* was updated.
@@ -130,6 +135,8 @@ export type RoomStateEventHandlerMap = {
type EmittedEvents = RoomStateEvent | BeaconEvent;
type EventHandlerMap = RoomStateEventHandlerMap & BeaconEventHandlerMap;
type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];
export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this);
private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
@@ -800,7 +807,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @param powerLevel - The power level of the member
* @returns true if the given power level is sufficient
*/
public hasSufficientPowerLevelFor(action: "ban" | "kick" | "redact", powerLevel: number): boolean {
public hasSufficientPowerLevelFor(
action: KeysMatching<Required<IPowerLevelsContent>, number>,
powerLevel: number,
): boolean {
const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
let powerLevels: IPowerLevelsContent = {};
@@ -953,7 +963,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
*/
public getJoinRule(): JoinRule {
const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, "");
const joinRuleContent: Partial<IJoinRuleEventContent> = joinRuleEvent?.getContent() ?? {};
const joinRuleContent: Partial<RoomJoinRulesEventContent> = joinRuleEvent?.getContent() ?? {};
return joinRuleContent["join_rule"] || JoinRule.Invite;
}
+128 -16
View File
@@ -68,6 +68,7 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { isPollEvent, Poll, PollEvent } from "./poll";
import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";
import * as utils from "../utils";
import { KnownMembership, Membership } from "../@types/membership";
// These constants are used as sane defaults when the homeserver doesn't support
@@ -474,6 +475,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.name = roomId;
this.normalizedName = roomId;
// Listen to our own receipt event as a more modular way of processing our own
// receipts. No need to remove the listener: it's on ourself anyway.
this.on(RoomEvent.Receipt, this.onReceipt);
// all our per-room timeline sets. the first one is the unfiltered ones;
// the subsequent ones are the filtered ones in no particular order.
this.timelineSets = [new EventTimelineSet(this, opts)];
@@ -1306,6 +1311,98 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
private onReceipt(event: MatrixEvent): void {
if (this.hasEncryptionStateEvent()) {
this.clearNotificationsOnReceipt(event);
}
}
private clearNotificationsOnReceipt(event: MatrixEvent): void {
// Like above, we have to listen for read receipts from ourselves in order to
// correctly handle notification counts on encrypted rooms.
// This fixes https://github.com/vector-im/element-web/issues/9421
// Figure out if we've read something or if it's just informational
// We need to work out what threads we've just recieved receipts for, so we
// know which ones to update. If we've received an unthreaded receipt, we'll
// need to update all threads.
let threadIds: string[] = [];
let hasUnthreadedReceipt = false;
const content = event.getContent();
for (const receiptGroup of Object.values(content)) {
for (const [receiptType, userReceipt] of Object.entries(receiptGroup)) {
if (!utils.isSupportedReceiptType(receiptType)) continue;
if (!userReceipt) continue;
for (const [userId, singleReceipt] of Object.entries(userReceipt)) {
if (!singleReceipt || typeof singleReceipt !== "object") continue;
const typedSingleReceipt = singleReceipt as Record<string, any>;
if (userId !== this.client.getUserId()) continue;
if (typedSingleReceipt.thread_id === undefined) {
hasUnthreadedReceipt = true;
} else if (typeof typedSingleReceipt.thread_id === "string") {
threadIds.push(typedSingleReceipt.thread_id);
}
}
}
}
if (hasUnthreadedReceipt) {
// If we have an unthreaded receipt, we need to update any threads that have a notification
// in them (because we know the receipt can't go backwards so we don't need to check any with
// no notifications: the number can only decrease from a receipt).
threadIds = this.getThreads()
.filter(
(thread) =>
this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0 ||
this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) > 0,
)
.map((thread) => thread.id);
threadIds.push("main");
}
for (const threadId of threadIds) {
// Work backwards to determine how many events are unread. We also set
// a limit for how back we'll look to avoid spinning CPU for too long.
// If we hit the limit, we assume the count is unchanged.
const maxHistory = 20;
const timeline = threadId === "main" ? this.getLiveTimeline() : this.getThread(threadId)?.liveTimeline;
if (!timeline) {
logger.warn(`Couldn't find timeline for thread ID ${threadId} in room ${this.roomId}`);
continue;
}
const events = timeline.getEvents();
let highlightCount = 0;
for (let i = events.length - 1; i >= 0; i--) {
if (i === events.length - maxHistory) return; // limit reached
const event = events[i];
if (this.hasUserReadEvent(this.client.getUserId()!, event.getId()!)) {
// If the user has read the event, then the counting is done.
break;
}
const pushActions = this.client.getPushActionsForEvent(event);
highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
}
// Note: we don't need to handle 'total' notifications because the counts
// will come from the server.
if (threadId === "main") {
this.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
} else {
this.setThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight, highlightCount);
}
}
}
/**
* Returns whether there are any devices in the room that are unverified
*
@@ -1512,18 +1609,31 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
/**
* Resets the thread notifications for this room
* Resets the total thread notifications for all threads in this room to zero,
* excluding any threads whose IDs are given in `exceptThreadIds`.
*
* If the room is not encrypted, also resets the highlight notification count to zero
* for the same set of threads.
*
* This is intended for use from the sync code since we calculate highlight notification
* counts locally from decrypted messages. We want to partially trust the total from the
* server such that we clear notifications when read receipts arrive. The weird name is
* intended to reflect this. You probably do not want to use this.
*
* @param exceptThreadIds - The thread IDs to exclude from the reset.
*/
public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void {
if (notificationsToKeep) {
for (const [threadId] of this.threadNotifications) {
if (!notificationsToKeep.includes(threadId)) {
this.threadNotifications.delete(threadId);
public resetThreadUnreadNotificationCountFromSync(exceptThreadIds: string[] = []): void {
const isEncrypted = this.hasEncryptionStateEvent();
for (const [threadId, notifs] of this.threadNotifications) {
if (!exceptThreadIds.includes(threadId)) {
notifs.total = 0;
if (!isEncrypted) {
notifs.highlight = 0;
}
}
} else {
this.threadNotifications.clear();
}
this.emit(RoomEvent.UnreadNotifications);
}
@@ -2345,6 +2455,15 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
receipts: this.cachedThreadReadReceipts.get(threadId) ?? [],
});
// Add the re-emitter before we start adding events to the thread so we don't miss events
this.reEmitter.reEmit(thread, [
ThreadEvent.Delete,
ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
// All read receipts should now come down from sync, we do not need to keep
// a reference to the cached receipts anymore.
this.cachedThreadReadReceipts.delete(threadId);
@@ -2360,13 +2479,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// and pass the event through this.
thread.addEvents(events, false);
this.reEmitter.reEmit(thread, [
ThreadEvent.Delete,
ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
const isNewer =
this.lastThread?.rootEvent &&
rootEvent?.localTimestamp &&
@@ -2966,7 +3078,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
[[], [], []],
);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
// When `threadSupport` is disabled treat all events as timelineEvents
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
}
}
+54 -39
View File
@@ -47,11 +47,6 @@ export type ThreadEventHandlerMap = {
[ThreadEvent.Delete]: (thread: Thread) => void;
} & EventTimelineSetHandlerMap;
/**
* @deprecated please use ThreadEventHandlerMap instead
*/
export type EventHandlerMap = ThreadEventHandlerMap;
interface IThreadOpts {
room: Room;
client: MatrixClient;
@@ -105,7 +100,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
*
* So it looks like this is only really relevant when initialEventsFetched
* is false, because as soon as the initial events have been fetched, we
* should have a timeline (I think).
* should have a proper chunk of timeline from the pagination fetch.
*
* If all replies in this thread are redacted, this is set to the root
* event. I'm not clear what the meaning of this is, since usually after the
@@ -138,6 +133,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* that we've already fetched them.
*/
public initialEventsFetched = !Thread.hasServerSideSupport;
private initalEventFetchProm: Promise<boolean> | undefined;
/**
* An array of events to add to the timeline once the thread has been initialised
@@ -377,20 +373,20 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
// When there's no server-side support, just add it to the end of the timeline.
this.addEventToTimeline(event, toStartOfTimeline);
this.client.decryptEventIfNeeded(event);
} else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
// When we've asked for the event to be added to the end, and we're
// not in the initial state, and this event belongs at the end, add it.
this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
this.addRelatedThreadEvent(event, toStartOfTimeline);
return;
} else if (this.initialEventsFetched) {
// If initial events have not been fetched, we are OK to throw away
// this event, because we are about to fetch all the events for this
// thread from the server.
} else if (!toStartOfTimeline && isNewestReply) {
// When we've asked for the event to be added to the end,
// and this event belongs at the end, add it.
this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event);
} else {
// Otherwise, we should add it, but we suspect it is out of order.
// This may be because we've just created the thread object and are
// still fetching events, in which case add it where we think is sensible
// and it will be removed and replaced with the events from the pagination
// request once that completes.
if (toStartOfTimeline) {
// If we're adding at the start of the timeline, it doesn't
// matter that it's out of order.
@@ -606,29 +602,48 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
await this.processRootEventPromise;
if (!this.initialEventsFetched) {
this.initialEventsFetched = true;
// fetch initial event to allow proper pagination
try {
// if the thread has regular events, this will just load the last reply.
// if the thread is newly created, this will load the root event.
if (this.replyCount === 0 && this.rootEvent) {
this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
this.liveTimeline.setPaginationToken(null, Direction.Backward);
} else {
await this.client.paginateEventTimeline(this.liveTimeline, {
backwards: true,
});
if (this.initalEventFetchProm) {
await this.initalEventFetchProm;
} else {
// fetch initial events to allow proper pagination
try {
// clear out any events that were added before the pagination request
// completed (eg. from sync). They'll be replaced by those from the pagination.
// Really, we should do this after the pagination request completes, but
// paginateEventTimeline does the request and adds the events in one go, so
// this would need a refactor in order to do. It's therefore possible there's
// a remaining race where an event comes in while the pagination request is
// happening.
this.timelineSet.resetLiveTimeline();
// if the thread has regular events, this will just load the last reply.
// if the thread is newly created, this will load the root event.
if (this.replyCount === 0 && this.rootEvent) {
this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
this.liveTimeline.setPaginationToken(null, Direction.Backward);
} else {
this.initalEventFetchProm = this.client.paginateEventTimeline(this.liveTimeline, {
backwards: true,
});
await this.initalEventFetchProm;
}
// We have now fetched the initial events, so set the flag. We need to do this before
// we actually add the events, so `this.addEvents` knows that it can now safely add
// them rather than buffer them in the pending event list. The main thing is that this
// must remain false while the async fetch happens, so we don't try to add events before
// the pagination has finished. The important thing is that we're not await-ing anything
// else between setting this and adding events, so no races.
this.initialEventsFetched = true;
for (const event of this.replayEvents!) {
this.addEvent(event, false);
}
this.replayEvents = null;
// just to make sure that, if we've created a timeline window for this thread before the thread itself
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
} catch (e) {
logger.error("Failed to load start of newly created thread: ", e);
this.initialEventsFetched = false;
}
for (const event of this.replayEvents!) {
this.addEvent(event, false);
}
this.replayEvents = null;
// just to make sure that, if we've created a timeline window for this thread before the thread itself
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
} catch (e) {
logger.error("Failed to load start of newly created thread: ", e);
this.initialEventsFetched = false;
}
}
@@ -689,7 +704,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* Return last reply to the thread, if known.
*/
public lastReply(
matches: (ev: MatrixEvent) => boolean = (ev): boolean => ev.isRelation(RelationType.Thread),
matches: (ev: MatrixEvent) => boolean = (ev): boolean => ev.isRelation(THREAD_RELATION_TYPE.name),
): MatrixEvent | null {
for (let i = this.timeline.length - 1; i >= 0; i--) {
const event = this.timeline[i];
+18 -27
View File
@@ -50,23 +50,31 @@ interface OidcRegistrationRequestBody {
}
/**
* Make the client registration request
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
* @param clientMetadata - registration metadata
* @returns resolves to the registered client id when registration is successful
* @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the registration request fails, or the response is invalid.
* Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
* @param clientMetadata - The metadata for the client which to register
* @returns Promise<string> resolved with registered clientId
* @throws when registration is not supported, on failed request or invalid response
*/
const doRegistration = async (
registrationEndpoint: string,
export const registerOidcClient = async (
delegatedAuthConfig: OidcClientConfig,
clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => {
if (!delegatedAuthConfig.registrationEndpoint) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"];
if (grantTypes.some((scope) => !delegatedAuthConfig.metadata.grant_types_supported.includes(scope))) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
// https://openid.net/specs/openid-connect-registration-1_0.html
const metadata: OidcRegistrationRequestBody = {
client_name: clientMetadata.clientName,
client_uri: clientMetadata.clientUri,
response_types: ["code"],
grant_types: ["authorization_code", "refresh_token"],
grant_types: grantTypes,
redirect_uris: clientMetadata.redirectUris,
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
@@ -82,7 +90,7 @@ const doRegistration = async (
};
try {
const response = await fetch(registrationEndpoint, {
const response = await fetch(delegatedAuthConfig.registrationEndpoint, {
method: Method.Post,
headers,
body: JSON.stringify(metadata),
@@ -108,20 +116,3 @@ const doRegistration = async (
}
}
};
/**
* Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
* @param clientMetadata - The metadata for the client which to register
* @returns Promise<string> resolved with registered clientId
* @throws when registration is not supported, on failed request or invalid response
*/
export const registerOidcClient = async (
delegatedAuthConfig: OidcClientConfig,
clientMetadata: OidcRegistrationClientMetadata,
): Promise<string> => {
if (!delegatedAuthConfig.registrationEndpoint) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
return doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
};
+12 -32
View File
@@ -15,17 +15,11 @@ limitations under the License.
*/
import { jwtDecode } from "jwt-decode";
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
import { IdTokenClaims, OidcMetadata, SigninResponse } from "oidc-client-ts";
import { logger } from "../logger";
import { OidcError } from "./error";
/**
* re-export for backwards compatibility
* @deprecated use OidcError
*/
export { OidcError as OidcDiscoveryError };
export type ValidatedIssuerConfig = {
authorizationEndpoint: string;
tokenEndpoint: string;
@@ -89,6 +83,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
requiredStringProperty(wellKnown, "revocation_endpoint"),
optionalStringProperty(wellKnown, "registration_endpoint"),
optionalStringProperty(wellKnown, "account_management_uri"),
optionalStringProperty(wellKnown, "device_authorization_endpoint"),
optionalStringArrayProperty(wellKnown, "account_management_actions_supported"),
requiredArrayValue(wellKnown, "response_types_supported", "code"),
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
@@ -124,6 +119,7 @@ export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
| "response_types_supported"
| "grant_types_supported"
| "code_challenge_methods_supported"
| "device_authorization_endpoint"
> & {
// MSC2965 extensions to the OIDC spec
account_management_uri?: string;
@@ -143,28 +139,7 @@ export function isValidatedIssuerMetadata(
validateOIDCIssuerWellKnown(metadata);
}
/**
* Standard JWT claims.
*
* @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
*/
interface JwtClaims {
[claim: string]: unknown;
/** The "iss" (issuer) claim identifies the principal that issued the JWT. */
iss?: string;
/** The "sub" (subject) claim identifies the principal that is the subject of the JWT. */
sub?: string;
/** The "aud" (audience) claim identifies the recipients that the JWT is intended for. */
aud?: string | string[];
/** The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. */
exp?: number;
// unused claims excluded
}
interface IdTokenClaims extends JwtClaims {
nonce?: string;
}
const decodeIdToken = (token: string): IdTokenClaims => {
export const decodeIdToken = (token: string): IdTokenClaims => {
try {
return jwtDecode<IdTokenClaims>(token);
} catch (error) {
@@ -182,7 +157,12 @@ const decodeIdToken = (token: string): IdTokenClaims => {
* @param nonce - nonce used in the authentication request
* @throws when id token is invalid
*/
export const validateIdToken = (idToken: string | undefined, issuer: string, clientId: string, nonce: string): void => {
export const validateIdToken = (
idToken: string | undefined,
issuer: string,
clientId: string,
nonce: string | undefined,
): void => {
try {
if (!idToken) {
throw new Error("No ID token");
@@ -207,7 +187,7 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
* If a nonce value was sent in the Authentication Request, a nonce Claim MUST be present and its value checked
* to verify that it is the same value as the one that was sent in the Authentication Request.
*/
if (claims.nonce !== nonce) {
if (nonce !== undefined && claims.nonce !== nonce) {
throw new Error("Invalid nonce");
}
@@ -275,7 +255,7 @@ export type BearerTokenResponse = {
expires_in?: number;
// from oidc-client-ts
expires_at?: number;
id_token?: string;
id_token: string;
};
/**
+32 -19
View File
@@ -25,8 +25,8 @@ import {
ICallStartedPrefixCondition,
IContainsDisplayNameCondition,
IEventMatchCondition,
IEventPropertyIsCondition,
IEventPropertyContainsCondition,
IEventPropertyIsCondition,
IPushRule,
IPushRules,
IRoomMemberCountCondition,
@@ -115,8 +115,14 @@ const DEFAULT_OVERRIDE_RULES: Record<string, IPushRule> = {
},
};
const EXPECTED_DEFAULT_OVERRIDE_RULE_IDS = [
// A special rule id for `EXPECTED_DEFAULT_OVERRIDE_RULE_IDS` and friends which denotes where user-defined rules live in the order.
const UserDefinedRules = Symbol("UserDefinedRules");
type OrderedRules = Array<string | typeof UserDefinedRules>;
const EXPECTED_DEFAULT_OVERRIDE_RULE_IDS: OrderedRules = [
RuleId.Master,
UserDefinedRules,
RuleId.SuppressNotices,
RuleId.InviteToSelf,
RuleId.MemberEvent,
@@ -151,8 +157,10 @@ const DEFAULT_UNDERRIDE_RULES: Record<string, IPushRule> = {
},
};
const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS = [
const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
UserDefinedRules,
RuleId.IncomingCall,
".org.matrix.msc3914.rule.room.call",
RuleId.EncryptedDM,
RuleId.DM,
RuleId.Message,
@@ -162,35 +170,40 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS = [
/**
* Make sure that each of the rules listed in `defaultRuleIds` is listed in the given set of push rules.
*
* @param kind - the kind of push rule set being merged.
* @param incomingRules - the existing set of known push rules for the user.
* @param defaultRules - a lookup table for the default definitions of push rules.
* @param defaultRuleIds - the IDs of the expected default push rules, in order.
* @param orderedRuleIds - the IDs of the expected push rules, in order.
*
* @returns A copy of `incomingRules`, with any missing default rules inserted in the right place.
*/
function mergeRulesWithDefaults(
kind: PushRuleKind,
incomingRules: IPushRule[],
defaultRules: Record<string, IPushRule>,
defaultRuleIds: string[],
orderedRuleIds: OrderedRules,
): IPushRule[] {
// Calculate the index after the last default rule in `incomingRules`
// to allow us to split the incomingRules into defaults and custom
let firstCustomRuleIndex = incomingRules.findIndex((r) => !r.default);
if (firstCustomRuleIndex < 0) firstCustomRuleIndex = incomingRules.length;
// Split the incomingRules into defaults and custom
const incomingDefaultRules = incomingRules.filter((rule) => rule.default);
const incomingCustomRules = incomingRules.filter((rule) => !rule.default);
function insertDefaultPushRule(ruleId: string): void {
if (ruleId in defaultRules) {
logger.warn(`Adding default global push rule ${ruleId}`);
function insertDefaultPushRule(ruleId: OrderedRules[number]): void {
if (ruleId === UserDefinedRules) {
// Re-insert any user-defined rules that were in `incomingRules`
newRules.push(...incomingCustomRules);
} else if (ruleId in defaultRules) {
logger.warn(`Adding default global ${kind} push rule ${ruleId}`);
newRules.push(defaultRules[ruleId]);
} else {
logger.warn(`Missing default global push rule ${ruleId}`);
logger.warn(`Missing default global ${kind} push rule ${ruleId}`);
}
}
let nextExpectedRuleIdIndex = 0;
const newRules: IPushRule[] = [];
for (const rule of incomingRules.slice(0, firstCustomRuleIndex)) {
const ruleIndex = defaultRuleIds.indexOf(rule.rule_id);
// Merge our expected rules (including the incoming custom rules) into the incoming default rules.
for (const rule of incomingDefaultRules) {
const ruleIndex = orderedRuleIds.indexOf(rule.rule_id);
if (ruleIndex === -1) {
// an unrecognised rule; copy it over
newRules.push(rule);
@@ -198,7 +211,7 @@ function mergeRulesWithDefaults(
}
while (ruleIndex > nextExpectedRuleIdIndex) {
// insert new rules
const defaultRuleId = defaultRuleIds[nextExpectedRuleIdIndex];
const defaultRuleId = orderedRuleIds[nextExpectedRuleIdIndex];
insertDefaultPushRule(defaultRuleId);
nextExpectedRuleIdIndex += 1;
}
@@ -208,12 +221,10 @@ function mergeRulesWithDefaults(
}
// Now copy over any remaining default rules
for (const ruleId of defaultRuleIds.slice(nextExpectedRuleIdIndex)) {
for (const ruleId of orderedRuleIds.slice(nextExpectedRuleIdIndex)) {
insertDefaultPushRule(ruleId);
}
// Finally any non-default rules that were in `incomingRules`
newRules.push(...incomingRules.slice(firstCustomRuleIndex));
return newRules;
}
@@ -281,12 +292,14 @@ export class PushProcessor {
// Merge the client-level defaults with the ones from the server
newRules.global.override = mergeRulesWithDefaults(
PushRuleKind.Override,
newRules.global.override,
DEFAULT_OVERRIDE_RULES,
EXPECTED_DEFAULT_OVERRIDE_RULE_IDS,
);
newRules.global.underride = mergeRulesWithDefaults(
PushRuleKind.Underride,
newRules.global.underride,
DEFAULT_UNDERRIDE_RULES,
EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS,
+307
View File
@@ -0,0 +1,307 @@
/*
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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { encodeUri } from "../utils";
import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
import { IToDeviceEvent } from "../sync-accumulator";
import { ServerSideSecretStorage } from "../secret-storage";
import { crypto } from "../crypto/crypto";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
import { Logger } from "../logger";
/**
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
*/
interface DehydratedDeviceResp {
device_id: string;
device_data: {
algorithm: string;
};
}
/**
* The response body of `POST /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/events`.
*/
interface DehydratedDeviceEventsResp {
events: IToDeviceEvent[];
next_batch: string;
}
/**
* The unstable URL prefix for dehydrated device endpoints
*/
export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1";
/**
* The name used for the dehydration key in Secret Storage
*/
const SECRET_STORAGE_NAME = "org.matrix.msc3814";
/**
* The interval between creating dehydrated devices. (one week)
*/
const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;
/**
* Manages dehydrated devices
*
* We have one of these per `RustCrypto`. It's responsible for
*
* * determining server support for dehydrated devices
* * creating new dehydrated devices when requested, including periodically
* replacing the dehydrated device with a new one
* * rehydrating a device when requested, and when present
*
* @internal
*/
export class DehydratedDeviceManager {
/** the secret key used for dehydrating and rehydrating */
private key?: Uint8Array;
/** the ID of the interval for periodically replacing the dehydrated device */
private intervalId?: ReturnType<typeof setInterval>;
public constructor(
private readonly logger: Logger,
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
private readonly secretStorage: ServerSideSecretStorage,
) {}
/**
* Return whether the server supports dehydrated devices.
*/
public async isSupported(): Promise<boolean> {
// call the endpoint to get a dehydrated device. If it returns an
// M_UNRECOGNIZED error, then dehydration is unsupported. If it returns
// a successful response, or an M_NOT_FOUND, then dehydration is supported.
// Any other exceptions are passed through.
try {
await this.http.authedRequest<DehydratedDeviceResp>(
Method.Get,
"/dehydrated_device",
undefined,
undefined,
{
prefix: UnstablePrefix,
},
);
} catch (error) {
const err = error as MatrixError;
if (err.errcode === "M_UNRECOGNIZED") {
return false;
} else if (err.errcode === "M_NOT_FOUND") {
return true;
}
throw error;
}
return true;
}
/**
* Start using device dehydration.
*
* - Rehydrates a dehydrated device, if one is available.
* - Creates a new dehydration key, if necessary, and stores it in Secret
* Storage.
* - If `createNewKey` is set to true, always creates a new key.
* - If a dehydration key is not available, creates a new one.
* - Creates a new dehydrated device, and schedules periodically creating
* new dehydrated devices.
*
* @param createNewKey - whether to force creation of a new dehydration key.
* This can be used, for example, if Secret Storage is being reset.
*/
public async start(createNewKey?: boolean): Promise<void> {
this.stop();
try {
await this.rehydrateDeviceIfAvailable();
} catch (e) {
// If rehydration fails, there isn't much we can do about it. Log
// the error, and create a new device.
this.logger.info("dehydration: Error rehydrating device:", e);
}
if (createNewKey) {
await this.resetKey();
}
await this.scheduleDeviceDehydration();
}
/**
* Return whether the dehydration key is stored in Secret Storage.
*/
public async isKeyStored(): Promise<boolean> {
return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME));
}
/**
* Reset the dehydration key.
*
* Creates a new key and stores it in secret storage.
*/
public async resetKey(): Promise<void> {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key));
this.key = key;
}
/**
* Get and cache the encryption key from secret storage.
*
* If `create` is `true`, creates a new key if no existing key is present.
*
* @returns the key, if available, or `null` if no key is available
*/
private async getKey(create: boolean): Promise<Uint8Array | null> {
if (this.key === undefined) {
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
if (keyB64 === undefined) {
if (!create) {
return null;
}
await this.resetKey();
} else {
this.key = decodeBase64(keyB64);
}
}
return this.key!;
}
/**
* Rehydrate the dehydrated device stored on the server.
*
* Checks if there is a dehydrated device on the server. If so, rehydrates
* the device and processes the to-device events.
*
* Returns whether or not a dehydrated device was found.
*/
public async rehydrateDeviceIfAvailable(): Promise<boolean> {
const key = await this.getKey(false);
if (!key) {
return false;
}
let dehydratedDeviceResp;
try {
dehydratedDeviceResp = await this.http.authedRequest<DehydratedDeviceResp>(
Method.Get,
"/dehydrated_device",
undefined,
undefined,
{
prefix: UnstablePrefix,
},
);
} catch (error) {
const err = error as MatrixError;
// We ignore M_NOT_FOUND (there is no dehydrated device, so nothing
// us to do) and M_UNRECOGNIZED (the server does not understand the
// endpoint). We pass through any other errors.
if (err.errcode === "M_NOT_FOUND" || err.errcode === "M_UNRECOGNIZED") {
this.logger.info("dehydration: No dehydrated device");
return false;
}
throw err;
}
this.logger.info("dehydration: dehydrated device found");
const rehydratedDevice = await this.olmMachine
.dehydratedDevices()
.rehydrate(
key,
new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id),
JSON.stringify(dehydratedDeviceResp.device_data),
);
this.logger.info("dehydration: device rehydrated");
let nextBatch: string | undefined = undefined;
let toDeviceCount = 0;
let roomKeyCount = 0;
const path = encodeUri("/dehydrated_device/$device_id/events", {
$device_id: dehydratedDeviceResp.device_id,
});
// eslint-disable-next-line no-constant-condition
while (true) {
const eventResp: DehydratedDeviceEventsResp = await this.http.authedRequest<DehydratedDeviceEventsResp>(
Method.Post,
path,
undefined,
nextBatch ? { next_batch: nextBatch } : {},
{
prefix: UnstablePrefix,
},
);
if (eventResp.events.length === 0) {
break;
}
toDeviceCount += eventResp.events.length;
nextBatch = eventResp.next_batch;
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
roomKeyCount += roomKeyInfos.length;
}
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
return true;
}
/**
* Creates and uploads a new dehydrated device.
*
* Creates and stores a new key in secret storage if none is available.
*/
public async createAndUploadDehydratedDevice(): Promise<void> {
const key = (await this.getKey(true))!;
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
this.logger.info("dehydration: uploaded device");
}
/**
* Schedule periodic creation of dehydrated devices.
*/
public async scheduleDeviceDehydration(): Promise<void> {
// cancel any previously-scheduled tasks
this.stop();
await this.createAndUploadDehydratedDevice();
this.intervalId = setInterval(() => {
this.createAndUploadDehydratedDevice().catch((error) => {
this.logger.error("Error creating dehydrated device:", error);
});
}, DEHYDRATION_INTERVAL);
}
/**
* Stop the dehydrated device manager.
*
* Cancels any scheduled dehydration tasks.
*/
public stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
}
+10 -3
View File
@@ -20,6 +20,7 @@ import {
KeysQueryRequest,
KeysUploadRequest,
OlmMachine,
PutDehydratedDeviceRequest,
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
@@ -29,9 +30,10 @@ import {
import { logger } from "../logger";
import { calculateRetryBackoff, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { logDuration, QueryDict, sleep } from "../utils";
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
import { AuthDict, UIAuthCallback } from "../interactive-auth";
import { UIAResponse } from "../@types/uia";
import { ToDeviceMessageId } from "../@types/event";
import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceManager";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
@@ -62,7 +64,7 @@ export class OutgoingRequestProcessor {
) {}
public async makeOutgoingRequest<T>(
msg: OutgoingRequest | UploadSigningKeysRequest,
msg: OutgoingRequest | UploadSigningKeysRequest | PutDehydratedDeviceRequest,
uiaCallback?: UIAuthCallback<T>,
): Promise<void> {
let resp: string;
@@ -102,6 +104,11 @@ export class OutgoingRequestProcessor {
);
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
return;
} else if (msg instanceof PutDehydratedDeviceRequest) {
const path = DehydrationUnstablePrefix + "/dehydrated_device";
await this.rawJsonRequest(Method.Put, path, {}, msg.body);
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
return;
} else {
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
@@ -169,7 +176,7 @@ export class OutgoingRequestProcessor {
}
const parsedBody = JSON.parse(body);
const makeRequest = async (auth: IAuthDict | null): Promise<UIAResponse<T>> => {
const makeRequest = async (auth: AuthDict | null): Promise<UIAResponse<T>> => {
const newBody: Record<string, any> = {
...parsedBody,
};
@@ -17,7 +17,7 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import { Curve25519AuthData, KeyBackupSession } from "../crypto-api/keybackup";
import { Curve25519AuthData, KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
import { Logger } from "../logger";
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
import { RustBackupManager } from "./backup";
@@ -76,7 +76,11 @@ type Configuration = {
export class PerSessionKeyBackupDownloader {
private stopped = false;
/** The version and decryption key to use with current backup if all set up correctly */
/**
* The version and decryption key to use with current backup if all set up correctly.
*
* Will not be set unless `hasConfigurationProblem` is `false`.
*/
private configuration: Configuration | null = null;
/** We remember when a session was requested and not found in backup to avoid query again too soon.
@@ -119,6 +123,24 @@ export class PerSessionKeyBackupDownloader {
backupManager.on(CryptoEvent.KeyBackupDecryptionKeyCached, this.onBackupStatusChanged);
}
/**
* Check if key download is successfully configured and active.
*
* @return `true` if key download is correctly configured and active; otherwise `false`.
*/
public isKeyBackupDownloadConfigured(): boolean {
return this.configuration !== null;
}
/**
* Return the details of the latest backup on the server, when we last checked.
*
* This is just a convenience method to expose {@link RustBackupManager.getServerBackupInfo}.
*/
public async getServerBackupInfo(): Promise<KeyBackupInfo | null | undefined> {
return await this.backupManager.getServerBackupInfo();
}
/**
* Called when a MissingRoomKey or UnknownMessageIndex decryption error is encountered.
*
@@ -410,7 +432,7 @@ export class PerSessionKeyBackupDownloader {
private async internalCheckFromServer(): Promise<Configuration | null> {
let currentServerVersion = null;
try {
currentServerVersion = await this.backupManager.requestKeyBackupVersion();
currentServerVersion = await this.backupManager.getServerBackupInfo();
} catch (e) {
this.logger.debug(`Backup: error while checking server version: ${e}`);
this.hasConfigurationProblem = true;
+31 -2
View File
@@ -58,6 +58,16 @@ interface KeyBackupCreationInfo {
export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap> {
/** Have we checked if there is a backup on the server which we can use */
private checkedForBackup = false;
/**
* The latest backup version on the server, when we last checked.
*
* If there was no backup on the server, `null`. If our attempt to check resulted in an error, `undefined`.
*
* Note that the backup was not necessarily verified.
*/
private serverBackupInfo: KeyBackupInfo | null | undefined = undefined;
private activeBackupVersion: string | null = null;
private stopped = false;
@@ -89,6 +99,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
return this.activeBackupVersion;
}
/**
* Return the details of the latest backup on the server, when we last checked.
*
* This normally returns a cached value, but if we haven't yet made a request to the server, it will fire one off.
* It will always return the details of the active backup if key backup is enabled.
*
* If there was no backup on the server, `null`. If our attempt to check resulted in an error, `undefined`.
*/
public async getServerBackupInfo(): Promise<KeyBackupInfo | null | undefined> {
// Do a validity check if we haven't already done one. The check is likely to fail if we don't yet have the
// backup keys -- but as a side-effect, it will populate `serverBackupInfo`.
await this.checkKeyBackupAndEnable(false);
return this.serverBackupInfo;
}
/**
* Determine if a key backup can be trusted.
*
@@ -242,18 +267,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/** Helper for `checkKeyBackup` */
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
logger.log("Checking key backup status...");
let backupInfo: KeyBackupInfo | null = null;
let backupInfo: KeyBackupInfo | null | undefined;
try {
backupInfo = await this.requestKeyBackupVersion();
} catch (e) {
logger.warn("Error checking for active key backup", e);
this.serverBackupInfo = undefined;
return null;
}
this.checkedForBackup = true;
if (backupInfo && !backupInfo.version) {
logger.warn("active backup lacks a useful 'version'; ignoring it");
backupInfo = undefined;
}
this.serverBackupInfo = backupInfo;
const activeVersion = await this.getActiveBackupVersion();
@@ -462,12 +490,13 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
}
return count;
}
/**
* Get information about the current key backup from the server
*
* @returns Information object from API or null if there is no active backup.
*/
public async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
private async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
return await requestKeyBackupVersion(this.http);
}
+1
View File
@@ -80,6 +80,7 @@ export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: Rus
verified,
signatures,
displayName: device.displayName,
dehydrated: device.isDehydrated,
});
}
+52 -18
View File
@@ -23,8 +23,11 @@ import { decryptAES, IEncryptedPayload } from "../crypto/aes";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { requestKeyBackupVersion } from "./backup";
import { IRoomEncryption } from "../crypto/RoomList";
import { CrossSigningKeyInfo } from "../crypto-api";
import { CrossSigningKeyInfo, Curve25519AuthData } from "../crypto-api";
import { RustCrypto } from "./rust-crypto";
import { KeyBackupInfo } from "../crypto-api/keybackup";
import { sleep } from "../utils";
import { encodeBase64 } from "../base64";
/**
* Determine if any data needs migrating from the legacy store, and do so.
@@ -105,7 +108,7 @@ export async function migrateFromLegacyCrypto(args: {
if (migrationState === MigrationState.NOT_STARTED) {
logger.info("Migrating data from legacy crypto store. Step 1: base data");
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle);
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle, logger);
migrationState = MigrationState.INITIAL_DATA_MIGRATED;
await legacyStore.setMigrationState(migrationState);
@@ -144,6 +147,7 @@ async function migrateBaseData(
legacyStore: CryptoStore,
pickleKey: Uint8Array,
storeHandle: RustSdkCryptoJs.StoreHandle,
logger: Logger,
): Promise<void> {
const migrationData = new RustSdkCryptoJs.BaseMigrationData();
migrationData.userId = new RustSdkCryptoJs.UserId(userId);
@@ -158,12 +162,41 @@ async function migrateBaseData(
const recoveryKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "m.megolm_backup.v1");
// If we have a backup recovery key, we need to try to figure out which backup version it is for.
// All we can really do is ask the server for the most recent version.
// All we can really do is ask the server for the most recent version and check if the cached key we have matches.
// It is possible that the backup has changed since last time his session was opened.
if (recoveryKey) {
const backupInfo = await requestKeyBackupVersion(http);
if (backupInfo) {
migrationData.backupVersion = backupInfo.version;
migrationData.backupRecoveryKey = recoveryKey;
let backupCallDone = false;
let backupInfo: KeyBackupInfo | null = null;
while (!backupCallDone) {
try {
backupInfo = await requestKeyBackupVersion(http);
backupCallDone = true;
} catch (e) {
logger.info("Failed to get backup version during migration, retrying in 2 seconds", e);
// Retry until successful, use simple constant delay
await sleep(2000);
}
}
if (backupInfo && backupInfo.algorithm == "m.megolm_backup.v1.curve25519-aes-sha2") {
// check if the recovery key matches, as the active backup version may have changed since the key was cached
// and the migration started.
try {
const decryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(recoveryKey);
const publicKey = (backupInfo.auth_data as Curve25519AuthData)?.public_key;
const isValid = decryptionKey.megolmV1PublicKey.publicKeyBase64 == publicKey;
if (isValid) {
migrationData.backupVersion = backupInfo.version;
migrationData.backupRecoveryKey = recoveryKey;
} else {
logger.debug(
"The backup key to migrate does not match the active backup version",
`Cached pub key: ${decryptionKey.megolmV1PublicKey.publicKeyBase64}`,
`Active pub key: ${publicKey}`,
);
}
} catch (e) {
logger.warn("Failed to check if the backup key to migrate matches the active backup version", e);
}
}
}
@@ -368,19 +401,20 @@ async function getAndDecryptCachedSecretKey(
legacyPickleKey: Uint8Array,
name: string,
): Promise<string | undefined> {
let encodedKey: IEncryptedPayload | null = null;
await legacyStore.doTxn("readonly", "account", (txn) => {
legacyStore.getSecretStorePrivateKey(
txn,
(k) => {
encodedKey = k as IEncryptedPayload | null;
},
name as keyof SecretStorePrivateKeys,
);
const key = await new Promise<any>((resolve) => {
legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
legacyStore.getSecretStorePrivateKey(txn, resolve, name as keyof SecretStorePrivateKeys);
});
});
return encodedKey === null ? undefined : await decryptAES(encodedKey, legacyPickleKey, name);
if (key && key.ciphertext && key.iv && key.mac) {
return await decryptAES(key as IEncryptedPayload, legacyPickleKey, name);
} else if (key instanceof Uint8Array) {
// This is a legacy backward compatibility case where the key was stored in clear.
return encodeBase64(key);
} else {
return undefined;
}
}
/**
+128 -55
View File
@@ -18,12 +18,13 @@ import anotherjson from "another-json";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import { KnownMembership } from "../@types/membership";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import type { IEncryptedEventInfo } from "../crypto/api";
import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { Room } from "../models/room";
import { RoomMember } from "../models/room-member";
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger, Logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { RoomEncryptor } from "./RoomEncryptor";
@@ -39,6 +40,7 @@ import {
CrossSigningStatus,
CryptoCallbacks,
Curve25519AuthData,
DecryptionFailureCode,
DeviceVerificationStatus,
EventEncryptionInfo,
EventShieldColour,
@@ -70,11 +72,17 @@ import { randomString } from "../randomstring";
import { ClientStoppedError } from "../errors";
import { ISignatures } from "../@types/signed";
import { encodeBase64 } from "../base64";
import { DecryptionError } from "../crypto/algorithms";
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
import { DehydratedDeviceManager } from "./DehydratedDeviceManager";
import { VerificationMethod } from "../types";
const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
const ALL_VERIFICATION_METHODS = [
VerificationMethod.Sas,
VerificationMethod.ScanQrCode,
VerificationMethod.ShowQrCode,
VerificationMethod.Reciprocate,
];
interface ISignableObject {
signatures?: ISignatures;
@@ -101,9 +109,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
private crossSigningIdentity: CrossSigningIdentity;
private readonly backupManager: RustBackupManager;
private outgoingRequestsManager: OutgoingRequestsManager;
private readonly perSessionBackupDownloader: PerSessionKeyBackupDownloader;
private readonly dehydratedDeviceManager: DehydratedDeviceManager;
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
public constructor(
@@ -142,14 +149,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
this.logger,
this.olmMachine,
this.http,
this.backupManager,
);
this.dehydratedDeviceManager = new DehydratedDeviceManager(
this.logger,
olmMachine,
http,
this.outgoingRequestProcessor,
secretStorage,
);
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
this.reemitter.reEmit(this.backupManager, [
@@ -206,6 +218,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
this.backupManager.stop();
this.outgoingRequestsManager.stop();
this.perSessionBackupDownloader.stop();
this.dehydratedDeviceManager.stop();
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
// cleaned up; in particular, the indexeddb connections will be closed, which means they
@@ -598,15 +611,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* Implementation of {@link CryptoApi#isCrossSigningReady}
*/
public async isCrossSigningReady(): Promise<boolean> {
const { publicKeysOnDevice, privateKeysInSecretStorage, privateKeysCachedLocally } =
await this.getCrossSigningStatus();
const { privateKeysInSecretStorage, privateKeysCachedLocally } = await this.getCrossSigningStatus();
const hasKeysInCache =
Boolean(privateKeysCachedLocally.masterKey) &&
Boolean(privateKeysCachedLocally.selfSigningKey) &&
Boolean(privateKeysCachedLocally.userSigningKey);
// The cross signing is ready if the public and private keys are available
return publicKeysOnDevice && (hasKeysInCache || privateKeysInSecretStorage);
const identity = await this.getOwnIdentity();
// Cross-signing is ready if the public identity is trusted, and the private keys
// are either cached, or accessible via secret-storage.
return !!identity?.isVerified() && (hasKeysInCache || privateKeysInSecretStorage);
}
/**
@@ -1206,6 +1221,23 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
}
/**
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
*/
public async isDehydrationSupported(): Promise<boolean> {
return await this.dehydratedDeviceManager.isSupported();
}
/**
* Implementation of {@link CryptoBackend#startDehydration}.
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
throw new Error("Device dehydration requires cross-signing and secret storage to be set up");
}
return await this.dehydratedDeviceManager.start(createNewKey);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
@@ -1677,52 +1709,93 @@ class EventDecryptor {
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
};
} catch (err) {
// We need to map back to regular decryption errors (used for analytics for example)
// The DecryptionErrors are used by react-sdk so is implicitly part of API, but poorly typed
if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) {
const content = event.getWireContent();
let jsError;
switch (err.code) {
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey: {
jsError = new DecryptionError(
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
"The sender's device has not sent us the keys for this message.",
{
session: content.sender_key + "|" + content.session_id,
},
);
this.perSessionBackupDownloader.onDecryptionKeyMissingError(
event.getRoomId()!,
event.getWireContent().session_id!,
);
break;
}
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
jsError = new DecryptionError(
"OLM_UNKNOWN_MESSAGE_INDEX",
"The sender's device has not sent us the keys for this message at this index.",
{
session: content.sender_key + "|" + content.session_id,
},
);
this.perSessionBackupDownloader.onDecryptionKeyMissingError(
event.getRoomId()!,
event.getWireContent().session_id!,
);
break;
}
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
// Just put it on the `UNABLE_TO_DECRYPT` bucket.
default: {
jsError = new DecryptionError("UNABLE_TO_DECRYPT", err.description, {
session: content.sender_key + "|" + content.session_id,
});
break;
}
}
throw jsError;
this.onMegolmDecryptionError(event, err, await this.perSessionBackupDownloader.getServerBackupInfo());
} else {
throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "Unknown error");
}
throw new DecryptionError("UNABLE_TO_DECRYPT", "Unknown error");
}
}
/**
* Handle a `MegolmDecryptionError` returned by the rust SDK.
*
* Fires off a request to the `perSessionBackupDownloader`, if appropriate, and then throws a `DecryptionError`.
*
* @param event - The event which could not be decrypted.
* @param err - The error from the Rust SDK.
* @param serverBackupInfo - Details about the current backup from the server. `null` if there is no backup.
* `undefined` if our attempt to check failed.
*/
private onMegolmDecryptionError(
event: MatrixEvent,
err: RustSdkCryptoJs.MegolmDecryptionError,
serverBackupInfo: KeyBackupInfo | null | undefined,
): never {
const content = event.getWireContent();
const errorDetails = { session: content.sender_key + "|" + content.session_id };
// If the error looks like it might be recoverable from backup, queue up a request to try that.
if (
err.code === RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey ||
err.code === RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex
) {
this.perSessionBackupDownloader.onDecryptionKeyMissingError(event.getRoomId()!, content.session_id!);
// If the server is telling us our membership at the time the event
// was sent, and it isn't "join", we use a different error code.
const membership = event.getMembershipAtEvent();
if (membership && membership !== KnownMembership.Join && membership !== KnownMembership.Invite) {
throw new DecryptionError(
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
"This message was sent when we were not a member of the room.",
errorDetails,
);
}
// If the event was sent before this device was created, we use some different error codes.
if (event.getTs() <= this.olmMachine.deviceCreationTimeMs) {
if (serverBackupInfo === null) {
throw new DecryptionError(
DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
"This message was sent before this device logged in, and there is no key backup on the server.",
errorDetails,
);
} else if (!this.perSessionBackupDownloader.isKeyBackupDownloadConfigured()) {
throw new DecryptionError(
DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
"This message was sent before this device logged in, and key backup is not working.",
errorDetails,
);
} else {
throw new DecryptionError(
DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP,
"This message was sent before this device logged in. Key backup is working, but we still do not (yet) have the key.",
errorDetails,
);
}
}
}
switch (err.code) {
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey:
throw new DecryptionError(
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
"The sender's device has not sent us the keys for this message.",
errorDetails,
);
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex:
throw new DecryptionError(
DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
"The sender's device has not sent us the keys for this message at this index.",
errorDetails,
);
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
// Just put it on the `UNKNOWN_ERROR` bucket.
default:
throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, err.description, errorDetails);
}
}
+12 -9
View File
@@ -35,6 +35,7 @@ import { TypedReEmitter } from "../ReEmitter";
import { MatrixEvent } from "../models/event";
import { EventType, MsgType } from "../@types/event";
import { defer, IDeferred } from "../utils";
import { VerificationMethod } from "../types";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
@@ -230,9 +231,9 @@ export class RustVerificationRequest
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
if (verification instanceof RustSdkCryptoJs.Sas) {
return "m.sas.v1";
return VerificationMethod.Sas;
} else if (verification instanceof RustSdkCryptoJs.Qr) {
return "m.reciprocate.v1";
return VerificationMethod.Reciprocate;
} else {
return null;
}
@@ -336,7 +337,7 @@ export class RustVerificationRequest
* @param method - the name of the verification method to use.
*/
public async startVerification(method: string): Promise<Verifier> {
if (method !== "m.sas.v1") {
if (method !== VerificationMethod.Sas) {
throw new Error(`Unsupported verification method ${method}`);
}
@@ -566,8 +567,10 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
// application to prompt the user to confirm their side.
if (this.callbacks === null && this.inner.hasBeenScanned()) {
this.callbacks = {
confirm: () => this.confirmScanning(),
cancel: () => this.cancel(),
confirm: (): void => {
this.confirmScanning();
},
cancel: (): void => this.cancel(),
};
}
@@ -756,10 +759,10 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */
const verificationMethodsByIdentifier: Record<string, RustSdkCryptoJs.VerificationMethod> = {
"m.sas.v1": RustSdkCryptoJs.VerificationMethod.SasV1,
"m.qr_code.scan.v1": RustSdkCryptoJs.VerificationMethod.QrCodeScanV1,
"m.qr_code.show.v1": RustSdkCryptoJs.VerificationMethod.QrCodeShowV1,
"m.reciprocate.v1": RustSdkCryptoJs.VerificationMethod.ReciprocateV1,
[VerificationMethod.Sas]: RustSdkCryptoJs.VerificationMethod.SasV1,
[VerificationMethod.ScanQrCode]: RustSdkCryptoJs.VerificationMethod.QrCodeScanV1,
[VerificationMethod.ShowQrCode]: RustSdkCryptoJs.VerificationMethod.QrCodeShowV1,
[VerificationMethod.Reciprocate]: RustSdkCryptoJs.VerificationMethod.ReciprocateV1,
};
/**
+17 -46
View File
@@ -32,7 +32,6 @@ import { deepCopy, defer, IDeferred, noUnsafeEventProps, promiseMapSeries, unsaf
import { Filter } from "./filter";
import { EventTimeline } from "./models/event-timeline";
import { logger } from "./logger";
import { InvalidStoreError, InvalidStoreState } from "./errors";
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
import {
IEphemeral,
@@ -593,25 +592,6 @@ export class SyncApi {
await keepaliveProm;
}
/**
* Is the lazy loading option different than in previous session?
* @param lazyLoadMembers - current options for lazy loading
* @returns whether or not the option has changed compared to the previous session */
private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise<boolean> {
// assume it was turned off before
// if we don't know any better
let lazyLoadMembersBefore = false;
const isStoreNewlyCreated = await this.client.store.isNewlyCreated();
if (!isStoreNewlyCreated) {
const prevClientOptions = await this.client.store.getClientOptions();
if (prevClientOptions) {
lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers;
}
return lazyLoadMembersBefore !== lazyLoadMembers;
}
return false;
}
private shouldAbortSync(error: MatrixError): boolean {
if (error.errcode === "M_UNKNOWN_TOKEN") {
// The logout already happened, we just need to stop.
@@ -649,9 +629,9 @@ export class SyncApi {
return filter;
};
private checkLazyLoadStatus = async (): Promise<void> => {
debuglog("Checking lazy load status...");
if (this.opts.lazyLoadMembers && this.client.isGuest()) {
private prepareLazyLoadingForSync = async (): Promise<void> => {
debuglog("Prepare lazy loading for sync...");
if (this.client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
@@ -661,23 +641,12 @@ export class SyncApi {
}
this.opts.filter.setLazyLoadMembers(true);
}
// need to vape the store when enabling LL and wasn't enabled before
debuglog("Checking whether lazy loading has changed in store...");
const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers);
if (shouldClear) {
this.storeIsInvalid = true;
const error = new InvalidStoreError(InvalidStoreState.ToggledLazyLoading, !!this.opts.lazyLoadMembers);
this.updateSyncState(SyncState.Error, { error });
// bail out of the sync loop now: the app needs to respond to this error.
// we leave the state as 'ERROR' which isn't great since this normally means
// we're retrying. The client must be stopped before clearing the stores anyway
// so the app should stop the client, clear the store and start it again.
logger.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers) {
this.syncOpts.crypto?.enableLazyLoading();
}
};
private storeClientOptions = async (): Promise<void> => {
try {
debuglog("Storing client options...");
await this.client.storeClientOptions();
@@ -757,14 +726,16 @@ export class SyncApi {
// 1) We need to get push rules so we can check if events should bing as we get
// them from /sync.
// 2) We need to get/create a filter which we can use for /sync.
// 3) We need to check the lazy loading option matches what was used in the
// stored sync. If it doesn't, we can't use the stored sync.
// 3) We need to prepare lazy loading for sync
// 4) We need to store the client options
// Now start the first incremental sync request: this can also
// take a while so if we set it going now, we can wait for it
// to finish while we process our saved sync data.
await this.getPushRules();
await this.checkLazyLoadStatus();
await this.prepareLazyLoadingForSync();
await this.storeClientOptions();
const { filterId, filter } = await this.getFilter();
if (!filter) return; // bail, getFilter failed
@@ -1355,11 +1326,11 @@ export class SyncApi {
const unreadThreadNotifications =
joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!];
if (unreadThreadNotifications) {
// Only partially reset unread notification
// We want to keep the client-generated count. Particularly important
// for encrypted room that refresh their notification count on event
// decryption
room.resetThreadUnreadNotificationCount(Object.keys(unreadThreadNotifications));
// This mirrors the logic above for rooms: take the *total* notification count from
// the server for unencrypted rooms or is it's zero. Any threads not present in this
// object implicitly have zero notifications, so start by clearing the total counts
// for all such threads.
room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications));
for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
if (!encrypted || unreadNotification.notification_count === 0) {
room.setThreadUnreadNotificationCount(
@@ -1380,7 +1351,7 @@ export class SyncApi {
}
}
} else {
room.resetThreadUnreadNotificationCount();
room.resetThreadUnreadNotificationCountFromSync();
}
joinObj.timeline = joinObj.timeline || ({} as ITimeline);
+191
View File
@@ -0,0 +1,191 @@
/*
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.
*/
/**
* This file is a secondary entrypoint for the js-sdk library, exposing utilities which might be useful for writing tests.
*
* In general, it should not be included in runtime applications.
*
* @packageDocumentation
*/
import { IContent, IEvent, IUnsigned, MatrixEvent } from "./models/event";
import { RoomMember } from "./models/room-member";
import { EventType } from "./@types/event";
import { DecryptionError } from "./crypto/algorithms";
import { DecryptionFailureCode } from "./crypto-api";
import { EventDecryptionResult } from "./common-crypto/CryptoBackend";
/**
* Create a {@link MatrixEvent}.
*
* @param opts - Values for the event.
*/
export function mkMatrixEvent(opts: {
/** Room ID of the event. */
roomId: string;
/** The sender of the event. */
sender: string;
/** The type of the event. */
type: EventType | string;
/** Optional `state_key` for the event. If unspecified, a non-state event is created. */
stateKey?: string;
/** Optional `origin_server_ts` for the event. If unspecified, the timestamp will be set to 0. */
ts?: number;
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
eventId?: string;
/** Content of the event. */
content: IContent;
/** Optional `unsigned` data for the event. */
unsigned?: IUnsigned;
}): MatrixEvent {
const event: Partial<IEvent> = {
type: opts.type,
room_id: opts.roomId,
sender: opts.sender,
content: opts.content,
event_id: opts.eventId ?? "$" + Math.random() + "-" + Math.random(),
origin_server_ts: opts.ts ?? 0,
unsigned: opts.unsigned,
};
if (opts.stateKey !== undefined) {
event.state_key = opts.stateKey;
}
const mxEvent = new MatrixEvent(event);
mxEvent.sender = {
userId: opts.sender,
membership: "join",
name: opts.sender,
rawDisplayName: opts.sender,
roomId: opts.sender,
getAvatarUrl: () => {},
getMxcAvatarUrl: () => {},
} as unknown as RoomMember;
return mxEvent;
}
/**
* Create a `MatrixEvent` representing a successfully-decrypted `m.room.encrypted` event.
*
* @param opts - Values for the event.
*/
export async function mkEncryptedMatrixEvent(opts: {
/** Room ID of the event. */
roomId: string;
/** The sender of the event. */
sender: string;
/** The type the event will have, once it has been decrypted. */
plainType: EventType | string;
/** The content the event will have, once it has been decrypted. */
plainContent: IContent;
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
eventId?: string;
}): Promise<MatrixEvent> {
const mxEvent = mkMatrixEvent({
type: EventType.RoomMessageEncrypted,
roomId: opts.roomId,
sender: opts.sender,
content: { algorithm: "m.megolm.v1.aes-sha2" },
eventId: opts.eventId,
});
await decryptExistingEvent(mxEvent, { plainType: opts.plainType, plainContent: opts.plainContent });
return mxEvent;
}
/**
* Create a `MatrixEvent` representing a `m.room.encrypted` event which could not be decrypted.
*
* @param opts - Values for the event.
*/
export async function mkDecryptionFailureMatrixEvent(opts: {
/** Room ID of the event. */
roomId: string;
/** The sender of the event. */
sender: string;
/** The reason code for the failure */
code: DecryptionFailureCode;
/** A textual reason for the failure */
msg: string;
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
eventId?: string;
}): Promise<MatrixEvent> {
const mxEvent = mkMatrixEvent({
type: EventType.RoomMessageEncrypted,
roomId: opts.roomId,
sender: opts.sender,
content: { algorithm: "m.megolm.v1.aes-sha2" },
eventId: opts.eventId,
});
const mockCrypto = {
decryptEvent: async (_ev): Promise<EventDecryptionResult> => {
throw new DecryptionError(opts.code, opts.msg);
},
} as Parameters<MatrixEvent["attemptDecryption"]>[0];
await mxEvent.attemptDecryption(mockCrypto);
return mxEvent;
}
/**
* Given an event previously returned by {@link mkDecryptionFailureMatrixEvent}, simulate a successful re-decryption
* attempt.
*
* @param mxEvent - The event that will be decrypted.
* @param opts - New data for the successful decryption.
*/
export async function decryptExistingEvent(
mxEvent: MatrixEvent,
opts: {
/** The type the event will have, once it has been decrypted. */
plainType: EventType | string;
/** The content the event will have, once it has been decrypted. */
plainContent: IContent;
},
): Promise<void> {
const decryptionResult: EventDecryptionResult = {
claimedEd25519Key: "",
clearEvent: {
type: opts.plainType,
content: opts.plainContent,
},
forwardingCurve25519KeyChain: [],
senderCurve25519Key: "",
untrusted: false,
};
const mockCrypto = {
decryptEvent: async (_ev): Promise<EventDecryptionResult> => decryptionResult,
} as Parameters<MatrixEvent["attemptDecryption"]>[0];
await mxEvent.attemptDecryption(mockCrypto);
}
+33
View File
@@ -23,3 +23,36 @@ limitations under the License.
export type * from "./@types/media";
export * from "./@types/membership";
export type * from "./@types/event";
export type * from "./@types/events";
export type * from "./@types/state_events";
/** The different methods for device and user verification */
export enum VerificationMethod {
/** Short authentication string (emoji or decimals).
*
* @see https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification
*/
Sas = "m.sas.v1",
/**
* Verification by showing a QR code which is scanned by the other device.
*
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
*/
ShowQrCode = "m.qr_code.show.v1",
/**
* Verification by scanning a QR code that is shown by the other device.
*
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
*/
ScanQrCode = "m.qr_code.scan.v1",
/**
* Verification by confirming that we have scanned a QR code.
*
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
*/
Reciprocate = "m.reciprocate.v1",
}
+29 -11
View File
@@ -26,8 +26,8 @@ import { parse as parseSdp, write as writeSdp } from "sdp-transform";
import { logger } from "../logger";
import { checkObjectHasKeys, isNullOrUndefined, recursivelyAssign } from "../utils";
import { IContent, MatrixEvent } from "../models/event";
import { EventType, ToDeviceMessageId } from "../@types/event";
import { MatrixEvent } from "../models/event";
import { EventType, TimelineEvents, ToDeviceMessageId } from "../@types/event";
import { RoomMember } from "../models/room-member";
import { randomString } from "../randomstring";
import {
@@ -293,13 +293,24 @@ function getCodecParamMods(isPtt: boolean): CodecParamsMod[] {
return mods;
}
type CallEventType =
| EventType.CallReplaces
| EventType.CallAnswer
| EventType.CallSelectAnswer
| EventType.CallNegotiate
| EventType.CallInvite
| EventType.CallCandidates
| EventType.CallHangup
| EventType.CallReject
| EventType.CallSDPStreamMetadataChangedPrefix;
export interface VoipEvent {
type: "toDevice" | "sendEvent";
eventType: string;
userId?: string;
opponentDeviceId?: string;
roomId?: string;
content: Record<string, unknown>;
content: TimelineEvents[CallEventType];
}
/**
@@ -406,7 +417,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
// If candidates arrive before we've picked an opponent (which, in particular,
// will happen if the opponent sends candidates eagerly before the user answers
// the call) we buffer them up here so we can then add the ones from the party we pick
private remoteCandidateBuffer = new Map<string, RTCIceCandidate[]>();
private remoteCandidateBuffer = new Map<string, MCallCandidates["candidates"]>();
private remoteAssertedIdentity?: AssertedIdentity;
private remoteSDPStreamMetadata?: SDPStreamMetadata;
@@ -1156,7 +1167,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.terminate(CallParty.Local, reason, !suppressEvent);
// We don't want to send hangup here if we didn't even get to sending an invite
if ([CallState.Fledgling, CallState.WaitLocalMedia].includes(this.state)) return;
const content: IContent = {};
const content: Omit<MCallHangupReject, "version" | "call_id" | "party_id" | "conf_id"> = {};
// Don't send UserHangup reason to older clients
if ((this.opponentVersion && this.opponentVersion !== 0) || reason !== CallErrorCode.UserHangup) {
content["reason"] = reason;
@@ -1916,7 +1927,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
if (this.opponentPartyId !== null) {
try {
await this.sendVoipEvent(EventType.CallSelectAnswer, {
selected_party_id: this.opponentPartyId,
selected_party_id: this.opponentPartyId!,
});
} catch (err) {
// This isn't fatal, and will just mean that if another party has raced to answer
@@ -2012,6 +2023,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
logger.debug(`Call ${this.callId} onNegotiateReceived() create an answer`);
this.sendVoipEvent(EventType.CallNegotiate, {
lifetime: CALL_TIMEOUT_MS,
description: this.peerConn!.localDescription?.toJSON(),
[SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
});
@@ -2444,13 +2456,17 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
/**
* @internal
*/
private async sendVoipEvent(eventType: string, content: object): Promise<void> {
const realContent = Object.assign({}, content, {
private async sendVoipEvent<K extends keyof Pick<TimelineEvents, CallEventType>>(
eventType: K,
content: Omit<TimelineEvents[K], "version" | "call_id" | "party_id" | "conf_id">,
): Promise<void> {
const realContent = {
...content,
version: VOIP_PROTO_VERSION,
call_id: this.callId,
party_id: this.ourPartyId,
conf_id: this.groupCallId,
});
} as TimelineEvents[K];
if (this.opponentDeviceId) {
const toDeviceSeq = this.toDeviceSeq++;
@@ -2729,7 +2745,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const candidates = this.candidateSendQueue;
this.candidateSendQueue = [];
++this.candidateSendTries;
const content = { candidates: candidates.map((candidate) => candidate.toJSON()) };
const content: Pick<MCallCandidates, "candidates"> = {
candidates: candidates.map((candidate) => candidate.toJSON()),
};
if (this.candidatesEnded) {
// If there are no more candidates, signal this by adding an empty string candidate
content.candidates.push({
@@ -2923,7 +2941,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.remoteCandidateBuffer.clear();
}
private async addIceCandidates(candidates: RTCIceCandidate[]): Promise<void> {
private async addIceCandidates(candidates: RTCIceCandidate[] | MCallCandidates["candidates"]): Promise<void> {
for (const candidate of candidates) {
if (
(candidate.sdpMid === null || candidate.sdpMid === undefined) &&
+2 -1
View File
@@ -34,6 +34,7 @@ export interface CallReplacesTarget {
export interface MCallBase {
call_id: string;
conf_id?: string;
version: string | number;
party_id?: string;
sender_session_id?: string;
@@ -82,7 +83,7 @@ export interface MCAllAssertedIdentity extends MCallBase {
}
export interface MCallCandidates extends MCallBase {
candidates: RTCIceCandidate[];
candidates: Omit<RTCIceCandidateInit, "usernameFragment">[];
}
export interface MCallHangupReject extends MCallBase {
+7
View File
@@ -35,6 +35,7 @@ import {
import { SummaryStatsReportGatherer } from "./stats/summaryStatsReportGatherer";
import { CallFeedStatsReporter } from "./stats/callFeedStatsReporter";
import { KnownMembership } from "../@types/membership";
import { CallMembershipData } from "../matrixrtc/CallMembership";
export enum GroupCallIntent {
Ring = "m.ring",
@@ -167,6 +168,7 @@ export interface IGroupCallDataChannelOptions {
export interface IGroupCallRoomState {
"m.intent": GroupCallIntent;
"m.type": GroupCallType;
"m.terminated"?: GroupCallTerminationReason;
"io.element.ptt"?: boolean;
// TODO: Specify data-channels
"dataChannelsEnabled"?: boolean;
@@ -196,6 +198,11 @@ export interface IGroupCallRoomMemberState {
"m.calls": IGroupCallRoomMemberCallState[];
}
// XXX: this hasn't made it into the MSC yet
export interface ExperimentalGroupCallRoomMemberState {
memberships: CallMembershipData[];
}
export enum GroupCallState {
LocalCallFeedUninitialized = "local_call_feed_uninitialized",
InitializingLocalCallFeed = "initializing_local_call_feed",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://typedoc.org/schema.json",
"plugin": ["typedoc-plugin-mdn-links", "typedoc-plugin-missing-exports", "typedoc-plugin-coverage"],
"coverageLabel": "TypeDoc",
"entryPoints": ["src/matrix.ts", "src/types.ts"],
"entryPoints": ["src/matrix.ts", "src/types.ts", "src/testing.ts"],
"excludeExternals": true,
"out": "_docs"
}
+1140 -808
View File
File diff suppressed because it is too large Load Diff