Compare commits

...

144 Commits

Author SHA1 Message Date
ElementRobot 2ec1fa6605 v25.2.0-rc.4 2023-05-16 14:22:04 +01:00
ElementRobot f15d682938 Prepare changelog for v25.2.0-rc.4 2023-05-16 14:22:02 +01:00
ElementRobot 21a10a2d14 v25.2.0-rc.3 2023-05-16 14:17:04 +01:00
ElementRobot fc02e550bd Prepare changelog for v25.2.0-rc.3 2023-05-16 14:17:02 +01:00
Michael Telatynski 78637a0689 Fix docs deployment 2023-05-16 14:15:27 +01:00
ElementRobot 4ca882fcd4 v25.2.0-rc.2 2023-05-16 13:58:35 +01:00
ElementRobot 13ee0eb7f5 Prepare changelog for v25.2.0-rc.2 2023-05-16 13:58:33 +01:00
ElementRobot cb018dfc80 Fix tsconfig-build.json 2023-05-16 13:58:05 +01:00
ElementRobot 7574dacdb3 v25.2.0-rc.1 2023-05-16 13:37:19 +01:00
ElementRobot 0c417b7c32 Prepare changelog for v25.2.0-rc.1 2023-05-16 13:37:17 +01:00
ElementRobot daf845d7bd Fix changelog_head.py script to be Python 3 compatible 2023-05-16 13:36:40 +01:00
Michael Telatynski 52792ec89b Fix CI failure on develop due to force merged PR and prettier failure (#3369) 2023-05-16 11:52:58 +00:00
Matthew Hodgson 6dc4a62e8c Merge pull request #3367 from matrix-org/matthew/fix-accumulated-sync-summaries
fix accumulated sync summaries - andy review
2023-05-16 12:34:54 +01:00
Matthew Hodgson 1cd8ea61ea merge 2023-05-16 12:08:02 +01:00
Matthew Hodgson 0c5eb277e4 incorporate andy review 2023-05-16 12:05:56 +01:00
Matthew Hodgson d459a91af3 correctly accumulate sync summaries. (#3366)
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 10:41:35 +00:00
Matthew Hodgson 18722d0031 correctly accumulate sync summaries.
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 11:34:06 +01:00
RiotRobot 5119934268 Merge branch 'master' into develop 2023-05-16 09:12:28 +01:00
RiotRobot 077da23d08 v25.1.1 2023-05-16 09:10:34 +01:00
RiotRobot 083b4cb17e Prepare changelog for v25.1.1 2023-05-16 09:10:31 +01:00
Richard van der Hoff 4316009401 Element-R: support for SigningKeysUploadRequest (#3365)
* OutgoingRequestProcessor: support for SigningKeysUploadRequest

* Tests

* Bump matrix-org/matrix-sdk-crypto-js

... to pick up bug fixes for outgoing requests
2023-05-15 19:14:05 +00:00
Richard van der Hoff 72f3c360b6 Add CryptoApi.getCrossSigningKeyId (#3360) 2023-05-15 18:46:33 +01:00
Enrico Schwendig fcbc195fbe Check permission on mute mic, only if no audio track exists. (#3359)
* check permission only if no audio track

* fix linter issues

* add missing tests for perfect negotiation pattern

* add null case in unit tests for audio muting

* fix issue with type MediaStream

* force right type of mock methode

* format code
2023-05-15 14:38:43 +00:00
Richard van der Hoff af38021d28 Deprecate device methods in MatrixClient (#3357) 2023-05-15 10:37:35 +00:00
Michael Telatynski 5e8cb9fa18 Fix typedoc release documentation deployment (#3358)
* Prune typedoc docs before generating new ones

* Only maintain 10 major versions

* Switch to deploy mechanism which doesn't mangle symlinks

* Convert absolute symlinks to relative
2023-05-15 09:14:35 +00:00
Michael Telatynski 6ef9f6c55e Enable better tree shaking (#3356) 2023-05-15 08:10:44 +00:00
Timo e6a3b0ebc0 Total summary count (#3351)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test

* add peer connections to summary report

* tests
2023-05-14 16:58:38 +00:00
Robin aaae55736f Keep measuring a call feed's volume after a stream replacement (#3361) 2023-05-13 17:54:29 +00:00
Timo 9e586ab634 Audio concealment (#3349)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test
2023-05-12 16:24:27 +00:00
Richard van der Hoff 7ff44d4a50 Integration test for bootstrapCrossSigning (#3355)
* Stub implementation of bootstrapCrossSigning

* Integration test for `bootstrapCrossSigning`
2023-05-12 16:19:18 +00:00
Richard van der Hoff 63abd00ca7 Element-R: Stub out isCrossSigningReady and isSecretStorageReady (#3354)
* Stub implementation of `isCrossSigningReady`

* Stub implementation of `isSecretStorageReady`

* add tests to meet quality gate

* factor out common

* Remove accidentally-added file
2023-05-12 12:21:52 +00:00
Richard van der Hoff 40f2579158 Pass SecretStorage into RustCrypto (#3353)
* Pass SecretStorage into RustCrypto

* Update src/rust-crypto/rust-crypto.ts
2023-05-12 09:38:33 +00:00
Richard van der Hoff ceb2a57feb Rename and move crypto.IBootstrapCrossSigningOpts (#3352)
* Define `UIAuthCallback` type and use in `IBootstrapCrossSigningOpts`

* Move `IBootstrapCrossSigningOpts` to `crypto-api` and rename

* Replace uses of `IBootstrapCrossSigningOpts`

... with `BootstrapCrossSigningOpts`

* Update src/crypto-api.ts
2023-05-11 18:41:58 +00:00
Enrico Schwendig 90e8336797 Do an ICE Restart if WebRTC become disconnected (#3341)
* Do an ice restart if ICE disconnected

  - Waite two seconds after disconnected
  - Remove check for finish ICE gathering and try to add each local candidate. Avoid race in multible ICE gathering

* Add tests for failed iceConnectionState

* suppress type check in unit test

* fix pr issues
2023-05-11 14:00:26 +00:00
Andy Balaam 73ca9c9ed2 Revert "Update JS-DevTools/npm-publish action to v2 (#3336)" (#3350)
Attempting to fix failure to publish to NPM due to an "invalid token"
error https://github.com/matrix-org/matrix-js-sdk/actions/runs/4926808655/jobs/8819822367 .

This reverts commit ca9263fa64.
2023-05-10 10:32:22 +00:00
RiotRobot cc065c2772 Resetting package fields for development 2023-05-09 15:14:29 +01:00
RiotRobot 028be0fee2 Merge branch 'master' into develop 2023-05-09 15:14:24 +01:00
RiotRobot d4500da59a v25.1.0 2023-05-09 15:12:52 +01:00
RiotRobot b6aef6772e Prepare changelog for v25.1.0 2023-05-09 15:12:49 +01:00
Michael Telatynski 49696cecbd Use WeakMap in ReEmitter to help enable garbage collection (#3344) 2023-05-09 13:24:34 +00:00
renovate[bot] 83cb52c89c Lock file maintenance (#3312)
* Lock file maintenance

* Fix types

* Iterate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-05-09 13:11:30 +00:00
renovate[bot] e82bae2c4d Update dependency @types/node to v18.16.6 (#3347)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 10:52:45 +00:00
renovate[bot] ee2b0204aa Update dependency @types/node to v18.16.4 (#3335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 04:58:36 +00:00
renovate[bot] 1ec7670f6a Update babel monorepo (#3332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-08 02:47:30 +00:00
Michael Telatynski 8ab2e10471 Update types to match spec (#3330) 2023-05-05 08:13:07 +00:00
renovate[bot] 8ff8685ae5 Update dependency rimraf to v5 (#3298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-04 21:43:38 +00:00
Florian Duros f3772cdf82 Avoid upload a new fallback key at every /sync (#3338) 2023-05-03 13:10:02 +00:00
Andy Balaam ee2f1cdfd4 Accumulate receipts for the main thread and unthreaded separately. (#3339)
Fixes matrix-org/element-web#24629
2023-05-02 15:57:53 +00:00
Florian Duros 0de73a0b3e Update @matrix-org/matrix-sdk-crypto-js to ^0.1.0-alpha.8 (#3337) 2023-05-02 15:53:36 +00:00
renovate[bot] be742e811c Update dependency @types/jest to v29.5.1 (#3333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:58:33 +00:00
renovate[bot] ca9263fa64 Update JS-DevTools/npm-publish action to v2 (#3336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:56:42 +00:00
renovate[bot] 9f619be08d Update peter-evans/create-pull-request digest to 284f54f (#3331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:40:31 +00:00
RiotRobot 7792254c12 v25.1.0-rc.1 2023-05-02 11:31:10 +01:00
RiotRobot fff41f1f27 Prepare changelog for v25.1.0-rc.1 2023-05-02 11:31:04 +01:00
renovate[bot] 3e0f9f582e Update all non-major dependencies (#3329)
* Update all non-major dependencies

* Create typedoc.json

* Update typedoc-plugin-missing-exports

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-28 08:41:28 +00:00
Michael Telatynski 47ba8cfa24 Correct interactive-auth types (#3328) 2023-04-28 07:52:47 +00:00
renovate[bot] 54bb4c8011 Update all non-major dependencies (#3295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-27 23:55:40 +00:00
Kerry 71e763263e add client method to remove pusher (#3324)
* add client method to remove pusher

* remove unused type
2023-04-27 21:49:35 +00:00
Andy Balaam 2ebcda2a55 Tests for the ReceiptAccumulator (#3326) 2023-04-27 13:06:17 +00:00
Janne Mareike Koschinski e10db6f7e9 Implement MSC 3981 (#3248)
* Implement MSC 3891

* Add necessary mocks to tests

* Only set recurse parameter if supported

* fix: address review comments

* task: unify unstable prefix code between client and tests

* Add test for relations recursion

* Make prettier happier :)

* Revert "task: unify unstable prefix code between client and tests"

This reverts commit f7401e05

* Fix broken tests
2023-04-27 13:01:48 +00:00
Michael Weimann 1e041a2957 Add Room.getLastLiveEvent and Room.getLastThread (#3321)
* Add room.getLastLiveEvent and remove room.lastThread

* Deprecate Room.lastThread

* Add comments about timestamps

* Improve lastThread prop doc

* Simplify test structure
2023-04-27 11:44:27 +00:00
Florian Duros 1631d6f3c0 Fix racing between one-time-keys processing and sync (#3327) 2023-04-27 10:21:01 +00:00
Andy Balaam 3d86821258 Move receipt-specific logic into ReceiptAccumulator (#3320)
* Extract receipt accumulation logic into ReceiptAccumulator

* Rename readReceipts to unthreadedReadReceipts

* Move AccumulatedReceipt into receipt-accumulator

* Move the logic for consuming events into ReceiptAccumulator
2023-04-27 07:25:16 +00:00
Enrico Schwendig 261bc81554 Make webrtc stats collection optional (#3307)
* stats: disable stats collection if interval zero

* stats: add groupcall property for stats interval

* stats: disable collecting webrtc stats by default

* add setup methode for group call stats

* suppress lint errors in test
2023-04-27 06:56:58 +00:00
Michael Telatynski 8f701f43fb Use safeSet in recursivelyAssign (#3323) 2023-04-27 06:13:38 +00:00
Richard van der Hoff 4bdb9111dd Fix incorrect uses of IAuthData (#3322)
`IAuthData` is the response, not the request
2023-04-26 18:15:58 +00:00
Andy Balaam 93e2135223 Factor out ReceiptAccumulator (#3319)
* Performance tests for receipt accumulation

* Split ReceiptAccumulator into its own module
2023-04-26 15:07:28 +00:00
Enrico Schwendig 56cb05aac0 Adjust negotiation process for pending answer process (#3314)
* add debug statements

* adjust negotiation process

* switch tp simpler proof setLocalDescription()

* fix second race in answer pending state and renegotiation trigger

* revert simpler proof setLocalDescription because of pre SDP munging. I will refactor this in an extra PR
* add state of answer pending process on the second received answer methode as well. Now in any case of receiving answer we take care of this state.

* Clean up pending state in error case
2023-04-26 15:04:50 +00:00
renovate[bot] 38d5b202c9 Update peter-evans/create-pull-request action to v5 (#3300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-26 12:47:31 +00:00
David Baker cfffa9c518 Fix lack of media when a user reconnects (#3318)
* Fix lack of media when a user reconnects

This fixes broken media when someone reconnects to the call after
a forced disconnect (when their old call gets replaced immediately
by a new call). We listen for changes in the call feeds and the tearing
down of the feeds for the old call caused us to remove the feed for
the new call.

Also adds the call to the calls map before it'as initialised, such that
it's the active call for the user/device when the feedsChanged event arrives,
otherwise we'll ignore the event.

* Fix tests
2023-04-26 10:00:08 +00:00
Florian Duros 73dbd709d8 Element-R: Stub findVerificationRequestDMInProgress and getStoredCrossSigningForUser (#3315)
* Add `findVerificationRequestDMInProgress` into `CryptoBackend` and stub it `rust-crypto`

* Add `getStoredCrossSigningForUser` into `CryptoBackend` and stub it `rust-crypto`
2023-04-25 17:52:20 +01:00
Michael Telatynski eef67e2c03 Deprecate MatrixClient::resolveRoomAlias (#3316) 2023-04-25 15:39:01 +00:00
RiotRobot e3498f0668 Resetting package fields for development 2023-04-25 09:44:25 +01:00
RiotRobot f04c147faa Merge branch 'master' into develop 2023-04-25 09:44:20 +01:00
RiotRobot 33bbd45f1e v25.0.0 2023-04-25 09:42:44 +01:00
RiotRobot fd91a534c7 Prepare changelog for v25.0.0 2023-04-25 09:42:41 +01:00
Richard van der Hoff c2afc357b9 Add some comments to OlmDecryption.decryptEvent (#3308) 2023-04-24 11:25:07 +00:00
Richard van der Hoff 1d3f67f2ce DeviceVerificationStatus: add new signedByOwner property (#3311) 2023-04-24 10:41:36 +00:00
Michael Telatynski 514e4f07f1 Update sonarcloud.yml 2023-04-24 08:38:14 +01:00
Florian Duros fbb1c4b2bd Element-R: wire up device lists (#3272)
* Add `getUserDeviceInfo` to `CryptoBackend` and old crypto impl

* Add `getUserDeviceInfo` WIP impl to `rust-crypto`

* Add tests for `downloadUncached`

* WIP test

* Fix typo and use `downloadDeviceToJsDevice`

* Add `getUserDeviceInfo` to `client.ts`

* Use new `Device` class instead of `IDevice`

* Add tests for `device-convertor`

* Add method description for `isInRustUserIds` in `rust-crypto.ts`

* Misc

* Fix typo

* Fix `rustDeviceToJsDevice`

* Fix comments and new one

* Review of `device.ts`

* Remove `getUserDeviceInfo` from `client.ts`

* Review of `getUserDeviceInfo` in `rust-crypto.ts`

* Fix typo in `index.ts`

* Review `device-converter.ts`

* Add documentation to `getUserDeviceInfo` in `crypto-api.ts`

* Last changes in comments
2023-04-21 14:03:02 +00:00
Florian Duros 63dea599b1 Update @matrix-org/matrix-sdk-crypto-js to 0.1.0-alpha.7 (#3309) 2023-04-20 16:37:09 +00:00
Janne Mareike Koschinski 743ba5f050 Increase log level for scheduler (#3152) 2023-04-20 12:31:58 +00:00
Michael Telatynski cb180b4195 Improve types (#3305) 2023-04-20 08:02:38 +00:00
Richard van der Hoff c805b7e29d DeviceVerificationStatus: take a param object (#3303) 2023-04-19 14:38:23 +01:00
Michael Weimann 8f6814450b Add proper deprecation doc to room fields (#3291) 2023-04-19 08:08:37 +00:00
Michael Telatynski 37e8391cde Node 20 support (#3302) 2023-04-19 09:11:32 +01:00
Enrico Schwendig 90234402a7 stats: calculate received media and ignore not added tracks (#3301)
* stats: calculate received media by ignore not added tracks

* stats: fix lint issue

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-19 06:50:27 +00:00
renovate[bot] 8ecf603d73 Update dependency eslint-plugin-jsdoc to v41 (#3297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-04-19 03:11:41 +00:00
renovate[bot] af243581ff Update typescript-eslint monorepo to v5.58.0 (#3296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
renovate[bot] 01afed9ff9 Update dawidd6/action-download-artifact digest to 246dbf4 (#3294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
Michael Telatynski 2687bb37fb Update tests.yml 2023-04-18 17:51:06 +01:00
Michael Telatynski 65b1a10803 Fix TimelineWindow getEvents exploding if no neigbouring timeline (#3285) 2023-04-18 12:45:58 +00:00
Michael Telatynski 9d230ef0d6 Use typedoc-plugin-versions (#3289)
* Install typedoc-plugin-versions

* Iterate

* Remove typedoc.json

* Simplify

* Simplify

* gnu vs bsd

* Iterate

* Iterate
2023-04-18 12:39:17 +00:00
Richard van der Hoff a03438f2af New CryptoApi.getDeviceVerificationStatus api (#3287)
* Element-R: implement `{get,set}TrustCrossSignedDevices`

A precursor to https://github.com/vector-im/element-web/issues/25092

* Pull out new `DeviceVerificationStatus`

Define a new base class to replace `DeviceTrustLevel`. The intention is to have
a cleaner interface which is easier to expose from the new crypto impl

* Define, and implement, a new `CryptoApi.getDeviceVerificationStatus`

This is similar to `checkDeviceTrust`, which we're deprecating, but:
 * is `async`, meaning we can implement it in Rust
 * Returns a `DeviceVerificationStatus` instead of a `DeviceTrustLevel`
 * Returns `null` rather than "not verified" if the device is unknown

* add some tests

* Export DeviceVerificationStatus as a proper class

... so that we can instantiate it in tests
2023-04-18 10:52:13 +00:00
RiotRobot c622e9260f v25.0.0-rc.1 2023-04-18 11:41:03 +01:00
RiotRobot 8bf53d6f90 Prepare changelog for v25.0.0-rc.1 2023-04-18 11:41:00 +01:00
Enrico Schwendig 8c30a3b0df Add max jitter and max packet loss (#3293)
* stats: add max jitter and max packet loss

* stats: add test for max jitter and packet loss

* stats: add build summery report tests

* stats: switch to packetsLost instead of packetsTotal
2023-04-18 10:28:59 +00:00
Richard van der Hoff c61d53eed0 Element-R: implement {get,set}TrustCrossSignedDevices (#3281)
A precursor to https://github.com/vector-im/element-web/issues/25092
2023-04-18 10:28:47 +00:00
Michael Telatynski 95f7d1d347 Add typedoc-plugin-mdn-links (#3292) 2023-04-18 10:00:37 +00:00
Michael Telatynski 72d70bb929 Improve types and their safety (#3290)
* Improve types and their safety

* Iterate
2023-04-18 07:32:40 +00:00
Kerry 4f67e59692 Annotate events with executed push rule (#3284)
* unit test paginating /notifications

* add push rule to event

* 1% more test coverage
2023-04-17 21:35:56 +00:00
Kerry d40d5c8a39 unit test paginating /notifications (#3283) 2023-04-16 22:33:52 +00:00
Tulir Asokan 87398ac555 Fix screen sharing on Firefox 113 (#3282)
`getCapabilities` exists now(?), but `setCodecPreferences` doesn't,
which means it would throw an error and fail the call.

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2023-04-15 05:08:21 +00:00
Michael Weimann de3d5ead42 Add Room.threadsTimelineSets doc (#3279)
* Add Room.threadsTimelineSets doc

* Tweak type checks

* Tweak type check again
2023-04-14 09:48:28 +00:00
Richard van der Hoff 1e1b571b28 Expose ServerSideSecretStorage independently of Crypto (#3280)
There is no reason to indirect secret storage via the Crypto layer, and
exposing it directly means it will work for Element-R.

Fixes: https://github.com/vector-im/element-web/issues/24982
2023-04-13 17:21:38 +01:00
Richard van der Hoff f400a7b1b2 Remove eventNames from TypedEventEmitter (#3276) 2023-04-13 10:43:57 +00:00
Enrico Schwendig a0bcb5777f Add Jitter to exported webrtc stats (#3270)
* stats: Add Jitter stats

* Update src/webrtc/stats/trackStatsReporter.ts

Co-authored-by: Robin <robin@robin.town>

* stats: Fix typos in tests

* stats: differences between 0 and undefined in jitter val

---------

Co-authored-by: Robin <robin@robin.town>
2023-04-13 08:51:13 +00:00
Richard van der Hoff f8a625eddb OutgoingRoomKeyRequest is only a type (#3278)
Followup to #3275

Fixes a warning from webpack:

    [element-js] WARNING in ../matrix-js-sdk/src/matrix.ts 46:0-61
    [element-js] "export 'OutgoingRoomKeyRequest' was not found in './crypto/store/base'
2023-04-13 07:43:19 +00:00
Richard van der Hoff 69a2a15b95 Add missing methods to ServerSideSecretStorgage (#3277)
(and improve some doc-comments)

Seems like I missed a couple of methods when I created this interface in #3267.
2023-04-13 06:08:53 +00:00
Richard van der Hoff b9d0596dd7 Fix typedoc warnings (#3275)
There aren't that many of these, so I've gone through and fixed them, and
configured the GHA workflow to complain if any creep back in.
2023-04-12 20:30:57 +00:00
Richard van der Hoff 72af8c193c Typescript fix to AccountDataClient (#3274)
* Typescript fix to `AccountDataClient`

obviously, `getAccountDataFromServer` can return null

* Update src/secret-storage.ts

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-12 18:38:05 +00:00
Richard van der Hoff ed8c326856 Pass UserId into receiveSyncChanges (#3273) 2023-04-12 18:33:12 +00:00
Michael Telatynski f5bf6b1be6 Update types to match reality (#3271) 2023-04-12 17:49:16 +00:00
Richard van der Hoff 0e19f8dc69 Split SecretStorage into two parts (#3267)
* Pull `SecretStorageCallbacks` out of `ICryptoCallbacks`

* Pull the storage part of SecretStorage out to a new class

* Move SecretSharing to a separate class

* Move `ISecretRequest` into `SecretSharing.ts`

* Pull out ISecretStorage interface, and use it

* Mark old `SecretStorage` as deprecated, and rename accesses to it

* Move a `SecretStorage` unit test into its own file

* Use new `SecretStorage` in a couple of places

* add some more unit tests

* Fix test file name

... to match the unit under test

* even more tests

* Add a load of comments

* Rename classes

* Fix some broken tsdoc links

* fix broken test

* Fix compaints about superlinear regex

* just one more test
2023-04-12 16:10:43 +00:00
Richard van der Hoff 6049c0bf37 Improve output in github actions for jest tests (#3269) 2023-04-11 18:53:10 +00:00
texuf a102253f30 Update storage interface so that save() is async to match indexeddb impl (#3221)
The only implementation of this is an async function, but I can’t await it because the interface hides the return type.

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-04-11 18:52:53 +00:00
Florian Duros f71d86f005 Element-R: pass device list change notifications into rust crypto-sdk (#3254) 2023-04-11 14:42:19 +01:00
RiotRobot 70e34ffb76 Resetting package fields for development 2023-04-11 14:00:55 +01:00
RiotRobot 91aa7b26e6 Merge branch 'master' into develop 2023-04-11 14:00:49 +01:00
renovate[bot] 5fb97fcce4 Lock file maintenance (#3268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 09:24:40 +00:00
Hugh Nimmo-Smith 3d1a450129 Export type for return of getCapabilities() (#3266)
* Export type for return of getCapabilities()

Renamed because it clashes with ICapabilities from embedded

* Export type for return of getCapabilities()

Renamed because it clashes with ICapabilities from embedded

* Rename to Capabilities
2023-04-06 12:46:44 +00:00
Michael Telatynski a58c5aacdf Update pull_request.yaml 2023-04-06 12:53:43 +01:00
Michael Weimann d7e165a279 Retry processing potential poll events after decryption (#3246)
* Retry processing potential poll events after decryption

* Point `typedoc` at `matrix.ts`, not `index.ts` (#3239)

This gets rid of the rather pointless "default" module in the generated docs.

* Split up, rename, and move `ISecretStorageKeyInfo` (#3242)

* Move SecretStorageKeyInfo interfaces out to a new module

* Replace usages of ISecretStorageKeyInfo with SecretStorageKeyDescription

* Skip clear text non-poll events

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-04-06 09:59:39 +00:00
renovate[bot] a57ee803f1 Update dependency typescript to v5 (#3262)
* Update dependency typescript to v5

* Update dependency typescript to v5

* Iterate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-06 09:54:02 +00:00
Enrico Schwendig 170a52b09f Measure whether we receive media and add the mute state as an exception (#3249)
* stats: add summery stats reporter

* stats: export summery stats reports

* stats: fix typo of event name

* stats: check promise condition for node 16 test linter

* stats: remove weak test to figure out memory leak

* stats: remove second weak test

* stats: add starting processing test

* stats: fix tests

* stats: fix typo in group call

* stats: fix stats report gathering test

* stats: reactivate promise merge

* stats: add track counter and track mute counter in summary stats

* stats: add summery calculation

* stats: fix PR issues

* stats: adjust summery reporter for inbound and mute state

* stats: check async state

* stats: switch from an `Or` to `And` condition for entire received media value

* stats: Add property description

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-06 09:32:09 +00:00
renovate[bot] 2be5889d18 Update peter-evans/create-pull-request digest to 38e0b6e (#3257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-06 09:20:40 +00:00
Michael Telatynski ca6b574bee Update upgrade_dependencies.yml (#3264) 2023-04-06 07:51:58 +00:00
Michael Telatynski 57b0172a2d Update pull_request.yaml 2023-04-06 09:17:30 +01:00
Michael Telatynski 53260ee25d Update pull_request.yaml (#3255) 2023-04-06 07:45:44 +00:00
renovate[bot] 964281322f Update all non-major dependencies (#3259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 18:38:55 +00:00
David Baker 7c3f483396 Fix another export type (#3265)
Another case where we were importing types as normal imports which
confuses bundlers.
2023-04-05 17:14:14 +00:00
Hugh Nimmo-Smith 59784aa9fe Support for MSC3882 revision 1 (#3228)
* Support for MSC3882 revision 1

* Additional comments

* Revised field names

* Use UnstableValue for capability
2023-04-05 16:12:29 +00:00
Florian Duros 72a2b6d571 Use processKeyCounts in sliding sync (#3253) 2023-04-05 15:39:46 +00:00
renovate[bot] 5854af0eae Update definitelyTyped (#3260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:39:24 +00:00
renovate[bot] acd3d3a804 Update peaceiris/actions-gh-pages digest to 373f7f2 (#3256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:23:40 +00:00
renovate[bot] 1b8c04a430 Update babel monorepo (#3258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:12:15 +00:00
renovate[bot] 378b73f8b8 Update typescript-eslint monorepo to v5.57.0 (#3261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:10:56 +00:00
Michael Telatynski c482a6ab15 Tidy up merge queue automation (#3252)
* Tidy up merge queue automation

* Iterate

* Iterate

* Iterate
2023-04-05 13:48:57 +00:00
Florian Duros 2daa429b77 Improve #3215 implementation (#3226)
* Improve key upload request

* Add fallback keys check

* Review fixes

* Add comments about sliding sync usage of `processKeyCounts`

* Review fixes

* Better wording
2023-04-05 12:35:10 +00:00
Richard van der Hoff 6ebbc15359 Move SecretStorage-related interfaces out to new module (#3244)
* Remove redundant `IAccountDataClient.getAccountData`

This is never called, so we may as well get rid of it

* Move a few more interfaces into `secret-storage.ts`

* Use interfaces from `secret-storage`

* Move IAccountDataClient to secret-storage

* Use `AccountDataClient` from `secret-storage`

* move SECRET_STORAGE_ALGORITHM_V1_AES to secret-storage

* Use `SECRET_STORAGE_ALGORITHM_V1_AES` from `secret-storage`

* Add a test case for the quality gate

* Update src/secret-storage.ts
2023-04-05 11:42:15 +00:00
Richard van der Hoff 9a840d484c Element-R: handle events which arrive before their keys (#3230)
* minor cleanups to the crypto tests

mostly, this is about using `testUtils.awaitDecryption` rather than custom
code. Some other cleanups too.

* Keep a record of events which are missing their keys

* Retry event decryption when we receive megolm keys
2023-04-05 10:00:08 +00:00
David Baker e89467c9fb Add an event emitted when a Call creates a PeerConnection (#3251)
For apps that need acces to the per connection as soon as it's created
for debugging etc.
2023-04-05 08:32:50 +00:00
Enrico Schwendig 0b396c005c indicator whether call members send media. (#3241)
* stats: add summery stats reporter

* stats: export summery stats reports

* stats: fix typo of event name

* stats: check promise condition for node 16 test linter

* stats: remove weak test to figure out memory leak

* stats: remove second weak test

* stats: add starting processing test

* stats: fix tests

* stats: fix typo in group call

* stats: fix stats report gathering test

* stats: reactivate promise merge

* stats: fix PR issues

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-05 06:48:12 +00:00
David Baker d05313f95e Switch some imports to type imports (#3250)
Having these as regular imports confuses Vite for some reason.
2023-04-04 15:31:16 +00:00
122 changed files with 7691 additions and 2361 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -8,7 +8,7 @@ on:
secrets:
ELEMENT_BOT_TOKEN:
required: true
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
jobs:
changelog:
name: Preview Changelog
+20 -27
View File
@@ -11,6 +11,12 @@ jobs:
- name: 🧮 Checkout code
uses: actions/checkout@v3
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
path: _docs
- name: 🔧 Yarn cache
uses: actions/setup-node@v3
with:
@@ -19,38 +25,25 @@ jobs:
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
- name: 📋 Copy to temp
- name: 🔨 Install symlinks
run: |
cp -a "./_docs" "$RUNNER_TEMP/"
sudo apt-get update
sudo apt-get install -y symlinks
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
- name: 🔪 Prepare
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: 📖 Generate docs
run: |
VERSION="${GITHUB_REF_NAME#v}"
[ ! -e "$VERSION" ] || rm -r $VERSION
cp -r $RUNNER_TEMP/_docs/ $VERSION
# Add the new directory to the index if it isn't there already
if ! grep -q ">Version $VERSION</a>" index.html; then
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
fi
yarn tpv purge --yes --out _docs --stale --major 10
yarn gendoc
symlinks -rc _docs
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@bd8c6b06eba6b3d25d72b7a1767993c0aeee42e7 # v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
publish_dir: .
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
git add . --all
git commit -m "Update docs"
git push
working-directory: _docs
npm:
name: Publish
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.3
uses: matrix-org/sonarcloud-workflow-action@v2.5
# 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:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
+8 -1
View File
@@ -65,7 +65,14 @@ jobs:
run: "yarn install"
- name: Generate Docs
run: "yarn run gendoc"
run: "yarn run gendoc --treatWarningsAsErrors"
# Upload artifact duplicates symlink contents so we do this to save 75% space
- name: Flatten symlink and write _redirects
run: |
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
find _docs -mindepth 1 -maxdepth 1 -type l -delete
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
- name: Upload Artifact
uses: actions/upload-artifact@v3
+19 -27
View File
@@ -8,6 +8,8 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
jobs:
jest:
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
@@ -38,28 +40,22 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
- name: Load metrics reporter
id: metrics
if: github.ref == 'refs/heads/develop'
run: |
echo "extra-reporter='--reporters=<rootDir>/spec/slowReporter.js'" >> $GITHUB_OUTPUT
- name: Run tests
run: |
yarn ${{ github.event_name == 'merge_group' && 'test' || 'coverage' }} \
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--ci \
--reporters github-actions ${{ steps.metrics.outputs.extra-reporter }} \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
./spec/${{ matrix.specs }}
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
- name: Move coverage files into place
if: github.event_name != 'merge_group'
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: github.event_name != 'merge_group'
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v3
with:
name: coverage
@@ -67,22 +63,6 @@ jobs:
coverage
!coverage/lcov-report
skip_sonar:
name: Skip SonarCloud on merge_queue
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
needs: jest
steps:
- name: Skip SonarCloud
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
matrix-react-sdk:
name: Downstream test matrix-react-sdk
if: github.event_name == 'merge_group'
@@ -91,7 +71,8 @@ jobs:
disable_coverage: true
matrix-js-sdk-sha: ${{ github.sha }}
# Hook for branch protection to work outside merge queues
# Hook for branch protection to skip downstream testing outside of merge queues
# and skip sonarcloud coverage within merge queues
downstream:
name: Downstream tests
runs-on: ubuntu-latest
@@ -99,5 +80,16 @@ jobs:
needs:
- matrix-react-sdk
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- if: needs.matrix-react-sdk.result != 'skipped' && needs.matrix-react-sdk.result != 'success'
run: exit 1
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@2b011faafdcbc9ceb11414d64d0573f37c774b04 # v4
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
@@ -31,8 +31,8 @@ jobs:
T-Task
- name: Enable automerge
uses: peter-evans/enable-pull-request-automerge@684fed02ccc9b5eefcf7d40b65b3cd44255bd5bc # v2
run: gh pr merge --merge --auto "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
env:
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+57
View File
@@ -1,3 +1,60 @@
Changes in [25.2.0-rc.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.2.0-rc.4) (2023-05-16)
============================================================================================================
## 🦖 Deprecations
* Deprecate device methods in MatrixClient ([\#3357](https://github.com/matrix-org/matrix-js-sdk/pull/3357)).
## ✨ Features
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
## 🐛 Bug Fixes
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
==================================================================================================
## 🐛 Bug Fixes
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
==================================================================================================
## 🦖 Deprecations
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
## ✨ Features
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
## 🐛 Bug Fixes
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
==================================================================================================
## 🚨 BREAKING CHANGES
* Change `Store.save()` to return a `Promise` ([\#3221](https://github.com/matrix-org/matrix-js-sdk/pull/3221)). Contributed by @texuf.
## ✨ Features
* Add typedoc-plugin-mdn-links ([\#3292](https://github.com/matrix-org/matrix-js-sdk/pull/3292)).
* Annotate events with executed push rule ([\#3284](https://github.com/matrix-org/matrix-js-sdk/pull/3284)). Contributed by @kerryarchibald.
* Element-R: pass device list change notifications into rust crypto-sdk ([\#3254](https://github.com/matrix-org/matrix-js-sdk/pull/3254)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
* Support for MSC3882 revision 1 ([\#3228](https://github.com/matrix-org/matrix-js-sdk/pull/3228)). Contributed by @hughns.
## 🐛 Bug Fixes
* Fix screen sharing on Firefox 113 ([\#3282](https://github.com/matrix-org/matrix-js-sdk/pull/3282)). Contributed by @tulir.
* Retry processing potential poll events after decryption ([\#3246](https://github.com/matrix-org/matrix-js-sdk/pull/3246)). Fixes vector-im/element-web#24568.
* Element-R: handle events which arrive before their keys ([\#3230](https://github.com/matrix-org/matrix-js-sdk/pull/3230)). Fixes vector-im/element-web#24489.
Changes in [24.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.1.0) (2023-04-11)
==================================================================================================
+39
View File
@@ -0,0 +1,39 @@
/* Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { Config } from "jest";
import { env } from "process";
const config: Config = {
testEnvironment: "node",
testMatch: ["<rootDir>/spec/**/*.spec.{js,ts}"],
setupFilesAfterEnv: ["<rootDir>/spec/setupTests.ts"],
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
};
// if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) {
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
reporters.push("<rootDir>/spec/slowReporter.js");
}
config.reporters = reporters;
}
export default config;
+13 -26
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "24.1.0",
"version": "25.2.0-rc.4",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -55,7 +55,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -101,13 +101,13 @@
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.35.0",
"eslint": "8.39.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-jsdoc": "^43.0.6",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^46.0.0",
@@ -119,30 +119,17 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.4",
"rimraf": "^4.0.0",
"prettier": "2.8.8",
"rimraf": "^5.0.0",
"terser": "^5.5.1",
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
"typedoc-plugin-missing-exports": "^1.0.0",
"typescript": "^4.5.3"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"<rootDir>/spec/**/*.spec.{js,ts}"
],
"setupFilesAfterEnv": [
"<rootDir>/spec/setupTests.ts"
],
"collectCoverageFrom": [
"<rootDir>/src/**/*.{js,ts}"
],
"coverageReporters": [
"text-summary",
"lcov"
],
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
"typedoc": "^0.24.0",
"typedoc-plugin-mdn-links": "^3.0.3",
"typedoc-plugin-missing-exports": "^2.0.0",
"typedoc-plugin-versions": "^0.2.3",
"typedoc-plugin-versions-cli": "^0.1.12",
"typescript": "^5.0.0"
},
"@casualbot/jest-sonar-reporter": {
"outputDirectory": "coverage",
+1 -1
View File
@@ -15,4 +15,4 @@ for line in sys.stdin:
break
found_first_header = True
elif not re.match(r"^=+$", line) and len(line) > 0:
print line
print(line)
+117
View File
@@ -0,0 +1,117 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";
/**
* Integration tests for cross-signing functionality.
*
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
let aliceClient: MatrixClient;
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,
});
await initCrypto(aliceClient);
});
afterEach(async () => {
await aliceClient.stopClient();
fetchMock.mockReset();
});
describe("bootstrapCrossSigning (before initialsync completes)", () => {
oldBackendOnly("publishes keys if none were yet published", async () => {
// have account_data requests return an empty object
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// ... and one to upload the cross-signing keys (with UIA)
fetchMock.post(
{ url: "path:/_matrix/client/unstable/keys/device_signing/upload", name: "upload-keys" },
{},
);
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};
// now bootstrap cross signing, and check it resolves successfully
await aliceClient.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
const keysBody = JSON.parse(keysOpts!.body as string);
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
// there should be a key of each type
// master key is signed by the device
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
// ssk and usk are signed by the master key
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
// check the publish call
expect(fetchMock.called("upload-sigs")).toBeTruthy();
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
const body = JSON.parse(sigsOpts!.body as string);
// there should be a signature for our device, by our self-signing key.
expect(body).toHaveProperty(
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
);
});
});
});
+208 -38
View File
@@ -48,6 +48,8 @@ import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
import { escapeRegExp } from "../../src/utils";
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
import { flushPromises } from "../test-utils/flushPromises";
const ROOM_ID = "!room:id";
@@ -624,10 +626,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(decryptedEvent.getContent().body).toEqual("42");
});
oldBackendOnly("Alice receives a megolm message before the session keys", async () => {
it("Alice receives a megolm message before the session keys", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
// https://github.com/vector-im/element-web/issues/2273
await startClientAndAwaitFirstSync();
// if we're using the old crypto impl, stub out some methods in the device manager.
@@ -667,7 +667,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual("m.bad.encrypted");
const event = room.getLiveTimeline().getEvents()[0];
// wait for a first attempt at decryption: should fail
await testUtils.awaitDecryption(event);
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
// now she gets the room_key event
syncResponder.sendOrQueueSyncResponse({
@@ -678,20 +682,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
await syncPromise(aliceClient);
const event = room.getLiveTimeline().getEvents()[0];
let decryptedEvent: MatrixEvent;
if (event.getContent().msgtype != "m.bad.encrypted") {
decryptedEvent = event;
} else {
decryptedEvent = await new Promise<MatrixEvent>((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}
expect(decryptedEvent.getContent().body).toEqual("42");
await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent().body).toEqual("42");
});
it("Alice gets a second room_key message", async () => {
@@ -1947,51 +1939,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
jest.useRealTimers();
});
function listenToUpload(): Promise<number> {
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
const keysCount = Object.keys(content?.one_time_keys || {}).length;
if (keysCount) resolve(keysCount);
const fallbackKeysCount = Object.keys(content?.fallback_keys || {}).length;
if (keysCount) resolve({ keysCount, fallbackKeysCount });
return {
one_time_key_counts: {
// The matrix client does `/upload` requests until 50 keys are uploaded
// We return here 60 to avoid the `/upload` request loop
signed_curve25519: keysCount ? 60 : keysCount,
},
};
};
// catch both r0 and v3 variants
fetchMock.post(
new URL("/_matrix/client/r0/keys/upload", aliceClient.getHomeserverUrl()).toString(),
listener,
{
for (const path of ["/_matrix/client/r0/keys/upload", "/_matrix/client/v3/keys/upload"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener, {
// These routes are already defined in the E2EKeyReceiver
// We want to overwrite the behaviour of the E2EKeyReceiver
overwriteRoutes: true,
},
);
fetchMock.post(
new URL("/_matrix/client/v3/keys/upload", aliceClient.getHomeserverUrl()).toString(),
listener,
{
overwriteRoutes: true,
},
);
});
}
});
}
it("should make key upload request after sync", async () => {
let uploadPromise = listenToUpload();
let uploadPromise = awaitKeyUploadRequest();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
syncResponder.sendOrQueueSyncResponse(getSyncResponse([]));
await syncPromise(aliceClient);
expect(await uploadPromise).toBeGreaterThan(0);
uploadPromise = listenToUpload();
// Verify that `/upload` is called on Alice's homesever
const { keysCount, fallbackKeysCount } = await uploadPromise;
expect(keysCount).toBeGreaterThan(0);
expect(fallbackKeysCount).toBe(0);
uploadPromise = awaitKeyUploadRequest();
syncResponder.sendOrQueueSyncResponse({
next_batch: 2,
device_one_time_keys_count: { signed_curve25519: 0 },
device_unused_fallback_key_types: [],
});
// Advance local date to 2 minutes
@@ -2000,7 +1992,185 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await syncPromise(aliceClient);
expect(await uploadPromise).toBeGreaterThan(0);
// After we set device_one_time_keys_count to 0
// a `/upload` is expected
const res = await uploadPromise;
expect(res.keysCount).toBeGreaterThan(0);
expect(res.fallbackKeysCount).toBeGreaterThan(0);
});
});
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 = {
device_keys: {
"@testing_florian1:matrix.org": {
EBMMPAFOPU: {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "EBMMPAFOPU",
keys: {
"curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w",
"ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:EBMMPAFOPU":
"XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw",
},
},
user_id: "@testing_florian1:matrix.org",
unsigned: {
device_display_name: "display name",
},
},
},
},
failures: {},
master_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["master"],
keys: {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:UKAQMJSJZC":
"q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA",
},
},
},
},
self_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["self_signing"],
keys: {
"ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo":
"YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA",
},
},
},
},
user_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["user_signing"],
keys: {
"ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs":
"Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ",
},
},
},
},
};
function awaitKeyQueryRequest(): Promise<Record<string, []>> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
// Resolve with request payload
resolve(content.device_keys);
// Return response of `/keys/query`
return queryResponseBody;
};
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
}
});
}
it("Download uncached keys for known user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
expect(devicesInfo.get(user)?.size).toBe(1);
// Convert the expected device to IDevice and check
expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU),
);
});
it("Download uncached keys for unknown user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@bob:xyz";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
// The old crypto has an empty map for `@bob:xyz`
// The new crypto does not have the `@bob:xyz` entry in `devicesInfo`
expect(devicesInfo.get(user)?.size).toBeFalsy();
});
it("Get devices from tacked users", async () => {
jest.useFakeTimers();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
// `user` will be added to the room
syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"]));
// Advance local date to 2 minutes
// The old crypto only runs the upload every 60 seconds
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
await syncPromise(aliceClient);
// Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
await flushPromises();
// Wait for alice to query `user` keys
await queryPromise;
// Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
// It will add `@testing_florian1:matrix.org` devices to the DeviceList
await flushPromises();
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]);
// We should only have the `user` in it
expect(devicesInfo.size).toBe(1);
// We are expecting only the EBMMPAFOPU device
expect(devicesInfo.get(user)!.size).toBe(1);
expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]),
);
});
});
});
+118 -1
View File
@@ -21,11 +21,13 @@ import {
EventStatus,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
IEvent,
MatrixClient,
MatrixEvent,
PendingEventOrdering,
RelationType,
Room,
} from "../../src/matrix";
import { logger } from "../../src/logger";
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
const userId = "@alice:localhost";
const userName = "Alice";
@@ -1164,6 +1167,117 @@ describe("MatrixClient event timelines", function () {
]);
});
it("should ensure thread events don't get reordered with recursive relations", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
const THREAD_ROOT_REACTION = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_ROOT.event_id!,
key: Math.random().toString(),
},
},
});
THREAD_ROOT_REACTION.localTimestamp += 2000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 3000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.threadSupport = true;
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({
dir: Direction.Backward,
limit: 3,
recurse: true,
}),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
describe("paginateEventTimeline for thread list timeline", function () {
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
@@ -1847,7 +1961,10 @@ describe("MatrixClient event timelines", function () {
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
buildRelationPaginationQuery({
dir: Direction.Backward,
from: "start_token",
}),
)
.respond(200, function () {
return {
+70 -3
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import HttpBackend from "matrix-mock-request";
import { Mocked } from "jest-mock";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
@@ -24,6 +25,7 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IFilterDefinition } from "../../src/filter";
import { ISearchResults } from "../../src/@types/search";
import { IStore } from "../../src/store";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
describe("MatrixClient", function () {
const userId = "@alice:localhost";
@@ -1127,22 +1129,51 @@ describe("MatrixClient", function () {
describe("requestLoginToken", () => {
it("should hit the expected API endpoint with UIA", async () => {
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
const response = {};
const uiaData = {};
const prom = client!.requestLoginToken(uiaData);
httpBackend!
.when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData })
.when("POST", "/unstable/org.matrix.msc3882/login/get_token", { auth: uiaData })
.respond(200, response);
await httpBackend!.flush("");
expect(await prom).toStrictEqual(response);
});
it("should hit the expected API endpoint without UIA", async () => {
const response = {};
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
const response = { login_token: "xyz", expires_in_ms: 5000 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
await httpBackend!.flush("");
// check that expires_in has been populated for compatibility with r0
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
});
it("should hit the r1 endpoint when capability is disabled", async () => {
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: false } } });
const response = { login_token: "xyz", expires_in_ms: 5000 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
await httpBackend!.flush("");
// check that expires_in has been populated for compatibility with r0
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
});
it("should hit the r0 endpoint for fallback", async () => {
httpBackend!.when("GET", "/capabilities").respond(200, {});
const response = { login_token: "xyz", expires_in: 5 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/token", {}).respond(200, response);
await httpBackend!.flush("");
expect(await prom).toStrictEqual(response);
// check that expires_in has been populated for compatibility with r1
expect(await prom).toStrictEqual({ ...response, expires_in_ms: 5000 });
});
});
@@ -1383,6 +1414,42 @@ describe("MatrixClient", function () {
await client!.uploadKeys();
});
});
describe("getCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(true);
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(false);
});
});
describe("setCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
setTrustCrossSignedDevices: jest.fn(),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
client!.setCryptoTrustCrossSignedDevices(true);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
client!.setCryptoTrustCrossSignedDevices(false);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
+10 -21
View File
@@ -662,41 +662,30 @@ describe("SlidingSyncSdk", () => {
});
it("can update device lists", () => {
client!.crypto!.processDeviceLists = jest.fn();
ext.onResponse({
device_lists: {
changed: ["@alice:localhost"],
left: ["@bob:localhost"],
},
});
// TODO: more assertions?
expect(client!.crypto!.processDeviceLists).toHaveBeenCalledWith({
changed: ["@alice:localhost"],
left: ["@bob:localhost"],
});
});
it("can update OTK counts", () => {
client!.crypto!.updateOneTimeKeyCount = jest.fn();
it("can update OTK counts and unused fallback keys", () => {
client!.crypto!.processKeyCounts = jest.fn();
ext.onResponse({
device_one_time_keys_count: {
signed_curve25519: 42,
},
});
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42);
ext.onResponse({
device_one_time_keys_count: {
not_signed_curve25519: 42,
// missing field -> default to 0
},
});
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0);
});
it("can update fallback keys", () => {
ext.onResponse({
device_unused_fallback_key_types: ["signed_curve25519"],
});
expect(client!.crypto!.getNeedsNewFallback()).toEqual(false);
ext.onResponse({
device_unused_fallback_key_types: ["not_signed_curve25519"],
});
expect(client!.crypto!.getNeedsNewFallback()).toEqual(true);
expect(client!.crypto!.processKeyCounts).toHaveBeenCalledWith({ signed_curve25519: 42 }, [
"signed_curve25519",
]);
});
});
+1 -1
View File
@@ -1698,7 +1698,7 @@ describe("SlidingSync", () => {
});
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
let timeoutId: NodeJS.Timeout;
let timeoutId: ReturnType<typeof setTimeout>;
return {
promise: new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ limitations under the License.
import DOMException from "domexception";
global.DOMException = DOMException;
global.DOMException = DOMException as typeof global.DOMException;
jest.mock("../src/http-api/utils", () => ({
...jest.requireActual("../src/http-api/utils"),
+4 -4
View File
@@ -375,17 +375,17 @@ export async function awaitDecryption(
// already
if (event.getClearContent() !== null) {
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
logger.log(`${Date.now()} event ${event.getId()} got decryption error; waiting`);
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
} else {
return event;
}
} else {
logger.log(`${Date.now()} event ${event.getId()} is not yet decrypted; waiting`);
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
}
return new Promise((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
resolve(ev);
});
});
+2
View File
@@ -239,6 +239,8 @@ export class MockRTCPeerConnection {
public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
}
public restartIce(): void {}
}
export class MockRTCRtpSender {
-1
View File
@@ -1011,7 +1011,6 @@ describe("Crypto", function () {
jest.setTimeout(10000);
const client = new TestClient("@a:example.com", "dev").client;
await client.initCrypto();
client.crypto!.getSecretStorageKey = jest.fn().mockResolvedValue(null);
client.crypto!.isCrossSigningReady = async () => false;
client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
+3 -2
View File
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixError } from "../../../src/http-api";
import { logger } from "../../../src/logger";
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
import { CryptoEvent } from "../../../src/crypto";
import { IDevice } from "../../../src/crypto/deviceinfo";
import { TestClient } from "../../TestClient";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
const PUSH_RULES_RESPONSE: Response = {
method: "GET",
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
await func({});
};
+58
View File
@@ -0,0 +1,58 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { DeviceVerification } from "../../../src";
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for DeviceInfo initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const verified = DeviceVerification.Verified;
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("deviceInfoToDevice", () => {
it("should convert a DeviceInfo to a Device", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(verified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.signatures.size).toBe(0);
});
});
});
+1 -2
View File
@@ -17,7 +17,6 @@ limitations under the License.
import "../../olm-loader";
import * as olmlib from "../../../src/crypto/olmlib";
import { IObject } from "../../../src/crypto/olmlib";
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/crypto/SecretStorage";
import { MatrixEvent } from "../../../src/models/event";
import { TestClient } from "../../TestClient";
import { makeTestClients } from "./verification/util";
@@ -28,7 +27,7 @@ import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { ISignatures } from "../../../src/@types/signed";
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
import { SecretStorageKeyDescription } from "../../../src/secret-storage";
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
+17
View File
@@ -374,6 +374,12 @@ describe("SAS verification", function () {
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const bobDeviceVerificationStatus = (await alice.client
.getCrypto()!
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
expect(aliceTrust.isTofu()).toBeTruthy();
@@ -381,6 +387,17 @@ describe("SAS verification", function () {
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const aliceDeviceVerificationStatus = (await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
const unknownDeviceVerificationStatus = await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "xyz");
expect(unknownDeviceVerificationStatus).toBe(null);
});
});
+1
View File
@@ -82,6 +82,7 @@ describe("EventTimelineSet", () => {
beforeEach(() => {
client = utils.mock(MatrixClient, "MatrixClient");
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
room = new Room(roomId, client, userA);
eventTimelineSet = new EventTimelineSet(room);
eventTimeline = new EventTimeline(eventTimelineSet);
-3
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import DOMException from "domexception";
import { mocked } from "jest-mock";
import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src";
@@ -33,8 +32,6 @@ describe("MatrixHttpApi", () => {
const DONE = 0;
global.DOMException = DOMException;
beforeEach(() => {
xhr = {
upload: {} as XMLHttpRequestUpload,
+300 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { Mocked, mocked } from "jest-mock";
import { logger } from "../../src/logger";
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
@@ -50,6 +50,12 @@ import {
MatrixScheduler,
Method,
Room,
EventTimelineSet,
PushRuleActionName,
TweakName,
RuleId,
IPushRule,
ConditionKind,
} from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
@@ -63,6 +69,8 @@ import { QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
jest.useFakeTimers();
@@ -2704,4 +2712,295 @@ describe("MatrixClient", function () {
});
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("SecretStorage wrappers", () => {
let mockSecretStorage: Mocked<ServerSideSecretStorageImpl>;
beforeEach(() => {
mockSecretStorage = {
getDefaultKeyId: jest.fn(),
hasKey: jest.fn(),
isStored: jest.fn(),
} as unknown as Mocked<ServerSideSecretStorageImpl>;
client["_secretStorage"] = mockSecretStorage;
});
it("hasSecretStorageKey", async () => {
mockSecretStorage.hasKey.mockResolvedValue(false);
expect(await client.hasSecretStorageKey("mykey")).toBe(false);
expect(mockSecretStorage.hasKey).toHaveBeenCalledWith("mykey");
});
it("isSecretStored", async () => {
const mockResult = { key: {} as SecretStorageKeyDescriptionAesV1 };
mockSecretStorage.isStored.mockResolvedValue(mockResult);
expect(await client.isSecretStored("mysecret")).toBe(mockResult);
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("mysecret");
});
it("getDefaultSecretStorageKeyId", async () => {
mockSecretStorage.getDefaultKeyId.mockResolvedValue("bzz");
expect(await client.getDefaultSecretStorageKeyId()).toEqual("bzz");
});
it("isKeyBackupKeyStored", async () => {
mockSecretStorage.isStored.mockResolvedValue(null);
expect(await client.isKeyBackupKeyStored()).toBe(null);
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("m.megolm_backup.v1");
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("Crypto wrappers", () => {
describe("exception if no crypto", () => {
it("isCrossSigningReady", () => {
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
});
it("bootstrapCrossSigning", () => {
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
});
it("isSecretStorageReady", () => {
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
});
});
describe("defer to crypto backend", () => {
let mockCryptoBackend: Mocked<CryptoBackend>;
beforeEach(() => {
mockCryptoBackend = {
isCrossSigningReady: jest.fn(),
bootstrapCrossSigning: jest.fn(),
isSecretStorageReady: jest.fn(),
stop: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockCryptoBackend;
});
it("isCrossSigningReady", async () => {
const testResult = "test";
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isCrossSigningReady()).toBe(testResult);
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
});
it("bootstrapCrossSigning", async () => {
const testOpts = {};
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
await client.bootstrapCrossSigning(testOpts);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
});
it("isSecretStorageReady", async () => {
client["cryptoBackend"] = mockCryptoBackend;
const testResult = "test";
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isSecretStorageReady()).toBe(testResult);
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
});
});
});
describe("paginateEventTimeline()", () => {
describe("notifications timeline", () => {
const unsafeNotification = {
actions: ["notify"],
room_id: "__proto__",
event: testUtils.mkMessage({
user: "@villain:server.org",
room: "!roomId:server.org",
msg: "I am nefarious",
}),
profile_tag: null,
read: true,
ts: 12345,
};
const goodNotification = {
actions: ["notify"],
room_id: "!favouriteRoom:server.org",
event: new MatrixEvent({
sender: "@bob:server.org",
room_id: "!roomId:server.org",
type: "m.call.invite",
content: {},
}),
profile_tag: null,
read: true,
ts: 12345,
};
const highlightNotification = {
actions: ["notify", { set_tweak: "highlight", value: true }],
room_id: "!roomId:server.org",
event: testUtils.mkMessage({
user: "@bob:server.org",
room: "!roomId:server.org",
msg: "I am highlighted banana",
}),
profile_tag: null,
read: true,
ts: 12345,
};
const setNotifsResponse = (notifications: any[] = []): void => {
const response: HttpLookup = {
method: "GET",
path: "/notifications",
data: { notifications: JSON.parse(JSON.stringify(notifications)) },
};
httpLookups = [response];
};
const callRule: IPushRule = {
actions: [PushRuleActionName.Notify],
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "m.call.invite",
},
],
default: true,
enabled: true,
rule_id: ".m.rule.call",
};
const masterRule: IPushRule = {
actions: [PushRuleActionName.DontNotify],
conditions: [],
default: true,
enabled: false,
rule_id: RuleId.Master,
};
const bananaRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
pattern: "banana",
rule_id: "banana",
default: false,
enabled: true,
} as IPushRule;
const pushRules = {
global: {
underride: [callRule],
override: [masterRule],
content: [bananaRule],
},
};
beforeEach(() => {
makeClient();
// this is how notif timeline is set up in react-sdk
const notifTimelineSet = new EventTimelineSet(undefined, {
timelineSupport: true,
pendingEvents: false,
});
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
client.setNotifTimelineSet(notifTimelineSet);
setNotifsResponse();
client.setPushRules(pushRules);
});
it("should throw when trying to paginate forwards", async () => {
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
await expect(
async () => await client.paginateEventTimeline(timeline, { backwards: false }),
).rejects.toThrow("paginateNotifTimeline can only paginate backwards");
});
it("defaults limit to 30 events", async () => {
jest.spyOn(client.http, "authedRequest");
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Get, "/notifications", {
limit: "30",
only: "highlight",
});
});
it("filters out unsafe notifications", async () => {
setNotifsResponse([unsafeNotification, goodNotification, highlightNotification]);
const timelineSet = client.getNotifTimelineSet()!;
const timeline = timelineSet.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
// badNotification not added to timeline
const timelineEvents = timeline.getEvents();
expect(timelineEvents.length).toEqual(2);
});
it("sets push details on events and add to timeline", async () => {
setNotifsResponse([goodNotification, highlightNotification]);
const timelineSet = client.getNotifTimelineSet()!;
const timeline = timelineSet.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
const [highlightEvent, goodEvent] = timeline.getEvents();
expect(highlightEvent.getPushActions()).toEqual({
notify: true,
tweaks: {
highlight: true,
},
});
expect(highlightEvent.getPushDetails().rule).toEqual({
...bananaRule,
kind: "content",
});
expect(goodEvent.getPushActions()).toEqual({
notify: true,
tweaks: {
highlight: false,
},
});
});
});
});
describe("pushers", () => {
const pusher = {
app_id: "test",
app_display_name: "Test App",
data: {},
device_display_name: "test device",
kind: "http",
lang: "en-NZ",
pushkey: "1234",
};
beforeEach(() => {
makeClient();
const response: HttpLookup = {
method: Method.Post,
path: "/pushers/set",
data: {},
};
httpLookups = [response];
jest.spyOn(client.http, "authedRequest").mockClear();
});
it("should make correct request to set pusher", async () => {
const result = await client.setPusher(pusher);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, pusher);
expect(result).toEqual({});
});
it("should make correct request to remove pusher", async () => {
const result = await client.removePusher(pusher.pushkey, pusher.app_id);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, {
pushkey: pusher.pushkey,
app_id: pusher.app_id,
kind: null,
});
expect(result).toEqual({});
});
});
});
+92
View File
@@ -17,6 +17,7 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
describe("MatrixEvent", () => {
it("should create copies of itself", () => {
@@ -216,4 +217,95 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.replyEventId).toBeUndefined();
});
});
describe("push details", () => {
const pushRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
pattern: "banana",
rule_id: "banana",
kind: "override",
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", () => {
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushDetails(actions, pushRule);
expect(event.getPushDetails()).toEqual({
actions,
rule: pushRule,
});
});
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 });
});
});
});
});
+32 -2
View File
@@ -14,13 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
import { M_POLL_START } from "matrix-events-sdk";
import { EventType, IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
import { Poll } from "../../../src/models/poll";
import { isPollEvent, Poll } from "../../../src/models/poll";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { flushPromises } from "../../test-utils/flushPromises";
import { mkEvent } from "../../test-utils/test-utils";
jest.useFakeTimers();
@@ -453,4 +456,31 @@ describe("Poll", () => {
expect(responses.getRelations()).toEqual([responseEvent]);
});
});
describe("isPollEvent", () => {
it("should return »false« for a non-poll event", () => {
const messageEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
user: mockClient.getSafeUserId(),
room: room.roomId,
});
expect(isPollEvent(messageEvent)).toBe(false);
});
it.each([[M_POLL_START.name], [M_POLL_RESPONSE.name], [M_POLL_END.name]])(
"should return »true« for a »%s« event",
(type: string) => {
const pollEvent = mkEvent({
event: true,
type,
content: {},
user: mockClient.getSafeUserId(),
room: room.roomId,
});
expect(isPollEvent(pollEvent)).toBe(true);
},
);
});
});
+2 -3
View File
@@ -1,6 +1,7 @@
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 { mockClientMethodsUser } from "../test-utils/client";
describe("NotificationService", function () {
const testUserId = "@ali:matrix.org";
@@ -45,9 +46,7 @@ describe("NotificationService", function () {
},
};
},
credentials: {
userId: testUserId,
},
...mockClientMethodsUser(testUserId),
supportsIntentionalMentions: () => true,
pushRules: {
device: {},
+215
View File
@@ -0,0 +1,215 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../../src/@types/read_receipts";
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
const roomId = "!foo:bar";
describe("ReceiptAccumulator", function () {
/*
* Note: at the time of writing, the ReceiptAccumulator uses the order of
* events from sync as the determinant of which one is most recent. This
* is correct, but inconsistent with other areas of the code, since we
* don't persist this order, so in other places we are forced to use the
* timestamp of events and hope that this matches up.
*
* The tests in this file provide ts values that are consistent with the
* sync order, so they should still pass if we change to using ts to
* determine order.
*
* Ideally, we would keep track of sync order so we can use it everywhere.
* This would mean we are consistent with how the homeserver sees receipts
* and notifications.
*/
it("Discards previous unthreaded receipts for the same user", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
});
it("Discards previous threaded receipts for the same user in the same thread", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
});
it("Collects multiple receipts for the same user if they are in different threads", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Collects multiple receipts for different users", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
]),
);
});
it("Collects last receipt for various users and threads", () => {
const acc = new ReceiptAccumulator();
// Below, if ts=1, this receipt is going to be superceded by another
// with ts=2
const receipts = [
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
];
acc.consumeEphemeralEvents(receipts);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// Only the ts=2 receipts make it through
expect(newEvent).toEqual(
newMultiReceipt([
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
]),
);
});
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
]),
);
});
});
const newReceipt = (
eventId: string,
receiptType: ReceiptType,
userId: string,
ts: number,
threadId: string | undefined = undefined,
) => {
return {
type: "m.receipt",
room_id: roomId,
content: {
[eventId]: {
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
},
};
};
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
return {
type: "m.receipt",
room_id: roomId,
content: Object.fromEntries(
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
return [
eventId,
{
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
];
}),
),
};
};
+35 -2
View File
@@ -38,6 +38,7 @@ function makeMockClient(opts: {
deviceId: string;
deviceKey?: string;
msc3882Enabled: boolean;
msc3882r0Only: boolean;
msc3886Enabled: boolean;
devices?: Record<string, Partial<DeviceInfo>>;
verificationFunction?: (
@@ -58,6 +59,17 @@ function makeMockClient(opts: {
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"org.matrix.msc3882.get_login_token": {
enabled: opts.msc3882Enabled,
},
},
};
},
getUserId() {
return opts.userId;
},
@@ -111,6 +123,7 @@ describe("Rendezvous", function () {
deviceId: "DEVICEID",
msc3886Enabled: false,
msc3882Enabled: true,
msc3882r0Only: true,
});
httpBackend.when("POST", "https://fallbackserver/rz").response = {
body: null,
@@ -166,7 +179,13 @@ describe("Rendezvous", function () {
await aliceRz.close();
});
it("no protocols", async function () {
async function testNoProtocols({
msc3882Enabled,
msc3882r0Only,
}: {
msc3882Enabled: boolean;
msc3882r0Only: boolean;
}) {
const aliceTransport = makeTransport("Alice");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -178,8 +197,9 @@ describe("Rendezvous", function () {
const alice = makeMockClient({
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: false,
msc3886Enabled: false,
msc3882Enabled,
msc3882r0Only,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
@@ -218,6 +238,14 @@ describe("Rendezvous", function () {
await aliceStartProm;
await bobStartPromise;
}
it("no protocols - r0", async function () {
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
});
it("no protocols - r1", async function () {
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
});
it("new device declines protocol with outcome unsupported", async function () {
@@ -233,6 +261,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -291,6 +320,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -349,6 +379,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -409,6 +440,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -477,6 +509,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
devices,
deviceKey: "aaaa",
+174 -5
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, PollStartEvent } from "matrix-events-sdk";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, Optional, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise } from "../test-utils/test-utils";
@@ -53,6 +53,8 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
import { Crypto } from "../../src/crypto";
import { mkThread } from "../test-utils/thread";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger";
import { IMessageOpts } from "../test-utils/test-utils";
describe("Room", function () {
const roomId = "!foo:bar";
@@ -62,9 +64,10 @@ describe("Room", function () {
const userD = "@dorothy:bar";
let room: Room;
const mkMessage = () =>
const mkMessage = (opts?: Partial<IMessageOpts>) =>
utils.mkMessage(
{
...opts,
event: true,
user: userA,
room: roomId,
@@ -112,9 +115,10 @@ describe("Room", function () {
room.client,
);
const mkThreadResponse = (root: MatrixEvent) =>
const mkThreadResponse = (root: MatrixEvent, opts?: Partial<IMessageOpts>) =>
utils.mkEvent(
{
...opts,
event: true,
type: EventType.RoomMessage,
user: userA,
@@ -164,6 +168,66 @@ describe("Room", function () {
room.client,
);
const addRoomMainAndThreadMessages = (
room: Room,
tsMain?: number,
tsThread?: number,
): { mainEvent?: MatrixEvent; threadEvent?: MatrixEvent } => {
const result: { mainEvent?: MatrixEvent; threadEvent?: MatrixEvent } = {};
if (tsMain) {
result.mainEvent = mkMessage({ ts: tsMain });
room.addLiveEvents([result.mainEvent]);
}
if (tsThread) {
const { rootEvent, thread } = mkThread({
room,
client: new TestClient().client,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
result.threadEvent = mkThreadResponse(rootEvent, { ts: tsThread });
thread.liveTimeline.addEvent(result.threadEvent, { toStartOfTimeline: true });
}
return result;
};
const addRoomThreads = (
room: Room,
thread1EventTs: Optional<number>,
thread2EventTs: Optional<number>,
): { thread1?: Thread; thread2?: Thread } => {
const result: { thread1?: Thread; thread2?: Thread } = {};
if (thread1EventTs !== null) {
const { rootEvent: thread1RootEvent, thread: thread1 } = mkThread({
room,
client: new TestClient().client,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
const thread1Event = mkThreadResponse(thread1RootEvent, { ts: thread1EventTs });
thread1.liveTimeline.addEvent(thread1Event, { toStartOfTimeline: true });
result.thread1 = thread1;
}
if (thread2EventTs !== null) {
const { rootEvent: thread2RootEvent, thread: thread2 } = mkThread({
room,
client: new TestClient().client,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
const thread2Event = mkThreadResponse(thread2RootEvent, { ts: thread2EventTs });
thread2.liveTimeline.addEvent(thread2Event, { toStartOfTimeline: true });
result.thread2 = thread2;
}
return result;
};
beforeEach(function () {
room = new Room(roomId, new TestClient(userA, "device").client, userA);
// mock RoomStates
@@ -171,6 +235,8 @@ describe("Room", function () {
room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState");
// @ts-ignore
room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState");
jest.spyOn(logger, "warn");
});
describe("getCreator", () => {
@@ -3261,7 +3327,7 @@ describe("Room", function () {
expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance);
});
it("adds related events to poll models", async () => {
it("adds related events to poll models and log errors", async () => {
const pollStartEvent = makePollStart("1");
const pollStartEvent2 = makePollStart("2");
const events = [pollStartEvent, pollStartEvent2];
@@ -3274,6 +3340,7 @@ describe("Room", function () {
},
},
});
const messageEvent = new MatrixEvent({
type: "m.room.messsage",
content: {
@@ -3281,6 +3348,19 @@ describe("Room", function () {
},
});
const errorEvent = new MatrixEvent({
type: M_POLL_START.name,
content: {
text: "Error!!!!",
},
});
const error = new Error("Test error");
mocked(client.decryptEventIfNeeded).mockImplementation(async (event: MatrixEvent) => {
if (event === errorEvent) throw error;
});
// init poll
await room.processPollEvents(events);
@@ -3289,7 +3369,7 @@ describe("Room", function () {
jest.spyOn(poll, "onNewRelation");
jest.spyOn(poll2, "onNewRelation");
await room.processPollEvents([pollResponseEvent, messageEvent]);
await room.processPollEvents([errorEvent, messageEvent, pollResponseEvent]);
// only called for relevant event
expect(poll.onNewRelation).toHaveBeenCalledTimes(1);
@@ -3297,6 +3377,32 @@ describe("Room", function () {
// only called on poll with relation
expect(poll2.onNewRelation).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith("Error processing poll event", errorEvent.getId(), error);
});
it("should retry on decryption", async () => {
const pollStartEventId = "poll1";
const pollStartEvent = makePollStart(pollStartEventId);
// simulate decryption failure
const isDecryptionFailureSpy = jest.spyOn(pollStartEvent, "isDecryptionFailure").mockReturnValue(true);
await room.processPollEvents([pollStartEvent]);
// do not expect a poll to show up for the room
expect(room.polls.get(pollStartEventId)).toBeUndefined();
// now emit a Decrypted event but keep the decryption failure
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
// still do not expect a poll to show up for the room
expect(room.polls.get(pollStartEventId)).toBeUndefined();
// clear decryption failure and emit a Decrypted event again
isDecryptionFailureSpy.mockRestore();
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
// the poll should now show up in the room's polls
const poll = room.polls.get(pollStartEventId);
expect(poll?.pollId).toBe(pollStartEventId);
});
});
@@ -3432,4 +3538,67 @@ describe("Room", function () {
expect(room.findPredecessor()).toBeNull();
});
});
describe("getLastLiveEvent", () => {
let lastEventInMainTimeline: MatrixEvent;
let lastEventInThread: MatrixEvent;
it("when there are no events, it should return undefined", () => {
expect(room.getLastLiveEvent()).toBeUndefined();
});
it("when there is only an event in the main timeline and there are no threads, it should return the last event from the main timeline", () => {
lastEventInMainTimeline = addRoomMainAndThreadMessages(room, 23).mainEvent!;
room.addLiveEvents([lastEventInMainTimeline]);
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
});
it("when there is no event in the room live timeline but in a thread, it should return the last event from the thread", () => {
lastEventInThread = addRoomMainAndThreadMessages(room, undefined, 42).threadEvent!;
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
});
describe("when there are events in both, the main timeline and threads", () => {
it("and the last event is in a thread, it should return the last event from the thread", () => {
lastEventInThread = addRoomMainAndThreadMessages(room, 23, 42).threadEvent!;
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
});
it("and the last event is in the main timeline, it should return the last event from the main timeline", () => {
lastEventInMainTimeline = addRoomMainAndThreadMessages(room, 42, 23).mainEvent!;
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
});
});
});
describe("getLastThread", () => {
it("when there is no thread, it should return undefined", () => {
expect(room.getLastThread()).toBeUndefined();
});
it("when there is only one thread, it should return this one", () => {
const { thread1 } = addRoomThreads(room, 23, null);
expect(room.getLastThread()).toBe(thread1);
});
it("when there are tho threads, it should return the one with the recent event I", () => {
const { thread2 } = addRoomThreads(room, 23, 42);
expect(room.getLastThread()).toBe(thread2);
});
it("when there are tho threads, it should return the one with the recent event II", () => {
const { thread1 } = addRoomThreads(room, 42, 23);
expect(room.getLastThread()).toBe(thread1);
});
it("when there is a thread with the last event ts undefined, it should return the thread with the defined event ts", () => {
const { thread2 } = addRoomThreads(room, undefined, 23);
expect(room.getLastThread()).toBe(thread2);
});
it("when the last event ts of all threads is undefined, it should return the last added thread", () => {
const { thread2 } = addRoomThreads(room, undefined, undefined);
expect(room.getLastThread()).toBe(thread2);
});
});
});
@@ -24,11 +24,12 @@ import {
KeysUploadRequest,
RoomMessageRequest,
SignatureUploadRequest,
SigningKeysUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("OutgoingRequestProcessor", () => {
@@ -80,6 +81,12 @@ describe("OutgoingRequestProcessor", () => {
"https://example.com/_matrix/client/v3/keys/signatures/upload",
],
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
[
"SigningKeysUploadRequest",
SigningKeysUploadRequest,
"POST",
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
],
];
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
@@ -171,6 +178,40 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle SigningKeysUploadRequests with UIA", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
// also create a UIA callback
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
return await makeRequest({ type: "test" });
};
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
// Now: check that it makes a matching HTTP request ...
const testResponse = '{"result":1}';
httpBackend
.when("POST", "/_matrix")
.check((req) => {
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
expect(JSON.parse(req.rawData)).toEqual({ foo: "bar", auth: { type: "test" } });
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
const markSentCallPromise = awaitCallToMarkAsSent();
@@ -0,0 +1,68 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceKeys, DeviceVerification } from "../../../src";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for QueryDevice initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("downloadDeviceToJsDevice", () => {
it("should convert a QueryDevice to a Device", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
signatures,
unsigned,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(DeviceVerification.Unverified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.signatures.size).toBe(0);
});
});
});
+104 -20
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,11 +22,12 @@ import { Mocked } from "jest-mock";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -35,16 +36,15 @@ afterEach(() => {
indexedDB = new IDBFactory();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe("RustCrypto", () => {
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should return a list", async () => {
@@ -57,8 +57,7 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should pass through unencrypted to-device messages", async () => {
@@ -92,16 +91,26 @@ describe("RustCrypto", () => {
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
});
it("should pass through one time key counts", async () => {
const oneTimeKeyCounts = new Map<string, number>([["signed_curve25519", 50]]);
await expect(rustCrypto.preprocessOneTimeKeyCounts(oneTimeKeyCounts)).resolves.not.toBeDefined();
});
it("isCrossSigningReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
});
it("should pass through unused fallback keys", async () => {
const unusedFallbackKeys = new Set(["signed_curve25519"]);
await expect(rustCrypto.preprocessUnusedFallbackKeys(unusedFallbackKeys)).resolves.not.toBeDefined();
});
it("getCrossSigningKeyId", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
});
it("bootstrapCrossSigning", async () => {
const rustCrypto = await makeTestRustCrypto();
await rustCrypto.bootstrapCrossSigning({});
});
it("isSecretStorageReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
});
describe("outgoing requests", () => {
@@ -151,7 +160,13 @@ describe("RustCrypto", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixHttpApi<any>,
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
@@ -216,8 +231,7 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should handle unencrypted events", () => {
@@ -240,4 +254,74 @@ describe("RustCrypto", () => {
expect(res.encrypted).toBeTruthy();
});
});
describe("get|setTrustCrossSignedDevices", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
it("should be true by default", () => {
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
it("should be easily turn-off-and-on-able", () => {
rustCrypto.setTrustCrossSignedDevices(false);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(false);
rustCrypto.setTrustCrossSignedDevices(true);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
});
describe("getDeviceVerificationStatus", () => {
let rustCrypto: RustCrypto;
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
beforeEach(() => {
olmMachine = {
getDevice: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
});
it("should call getDevice", async () => {
olmMachine.getDevice.mockResolvedValue({
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
isLocallyTrusted: jest.fn().mockReturnValue(false),
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
} as unknown as RustSdkCryptoJs.Device);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(olmMachine.getDevice.mock.calls[0][0].toString()).toEqual("@user:domain");
expect(olmMachine.getDevice.mock.calls[0][1].toString()).toEqual("device");
expect(res?.crossSigningVerified).toBe(false);
expect(res?.localVerified).toBe(false);
expect(res?.signedByOwner).toBe(false);
});
it("should return null for unknown device", async () => {
olmMachine.getDevice.mockResolvedValue(undefined);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(res).toBe(null);
});
});
});
/** build a basic RustCrypto instance for testing
*
* just provides default arguments for initRustCrypto()
*/
async function makeTestRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }> = {} as MatrixClient["http"],
userId: string = TEST_USER,
deviceId: string = TEST_DEVICE_ID,
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage);
}
+270
View File
@@ -0,0 +1,270 @@
/*
Copyright 2019, 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mocked } from "jest-mock";
import {
AccountDataClient,
PassphraseInfo,
SecretStorageCallbacks,
SecretStorageKeyDescriptionAesV1,
SecretStorageKeyDescriptionCommon,
ServerSideSecretStorageImpl,
trimTrailingEquals,
} from "../../src/secret-storage";
import { calculateKeyCheck } from "../../src/crypto/aes";
import { randomString } from "../../src/randomstring";
describe("ServerSideSecretStorageImpl", function () {
describe(".addKey", function () {
it("should allow storing a default key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
// it should have made up a 32-character key id
expect(result.keyId.length).toEqual(32);
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should allow storing a key with an explicit id", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
// it should have made up a 32-character key id
expect(result.keyId).toEqual("myKeyId");
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
"m.secret_storage.key.myKeyId",
result.keyInfo,
);
});
it("should allow storing a key with a name", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
expect(result.keyInfo.name).toEqual("mykey");
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should allow storing a key with a passphrase", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const passphrase: PassphraseInfo = {
algorithm: "m.pbkdf2",
iterations: 125,
salt: "saltygoodness",
bits: 256,
};
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
passphrase,
});
expect(result.keyInfo.passphrase).toEqual(passphrase);
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should complain about invalid algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
});
});
describe("getKey", function () {
it("should return the specified key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.key.my_key") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
const result = await secretStorage.getKey("my_key");
expect(result).toEqual(["my_key", storedKey]);
});
it("should return the default key if none is specified", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.default_key") {
return { key: "default_key_id" } as unknown as T;
} else if (eventType === "m.secret_storage.key.default_key_id") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
const result = await secretStorage.getKey();
expect(result).toEqual(["default_key_id", storedKey]);
});
it("should return null if the key is not found", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
// @ts-ignore
accountDataAdapter.getAccountDataFromServer.mockResolvedValue(null);
const result = await secretStorage.getKey("my_key");
expect(result).toEqual(null);
});
});
describe("checkKey", function () {
it("should return true for a correct key check", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const myKey = new TextEncoder().encode(randomString(32));
const { iv, mac } = await calculateKeyCheck(myKey);
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv,
mac,
};
const result = await secretStorage.checkKey(myKey, keyInfo);
expect(result).toBe(true);
});
it("should return false for an incorrect key check", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const { iv, mac } = await calculateKeyCheck(new TextEncoder().encode("badkey"));
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv,
mac,
};
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
expect(result).toBe(false);
});
it("should raise for an unknown algorithm", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "bad_alg",
iv: "iv",
mac: "mac",
};
await expect(() => secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo)).rejects.toThrow(
"Unknown algorithm",
);
});
// XXX: really???
it("should return true for an absent mac", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv: "iv",
mac: "",
};
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
expect(result).toBe(true);
});
});
describe("store", () => {
it("should ignore keys with unknown algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const mockCallbacks = { getSecretStorageKey: jest.fn() } as Mocked<SecretStorageCallbacks>;
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, mockCallbacks);
// stub out getAccountData to return a key with an unknown algorithm
const storedKey = { algorithm: "badalg" } as SecretStorageKeyDescriptionCommon;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.key.keyid") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
// suppress the expected warning on the console
jest.spyOn(console, "warn").mockImplementation();
// now attempt the store
await secretStorage.store("mysecret", "supersecret", ["keyid"]);
// we should have stored... nothing
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith("mysecret", { encrypted: {} });
// ... and emitted a warning.
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("unknown algorithm"));
});
});
});
describe("trimTrailingEquals", () => {
it("should strip trailing =", () => {
expect(trimTrailingEquals("ab=c===")).toEqual("ab=c");
});
it("should leave strings without trailing = alone", () => {
expect(trimTrailingEquals("ab=c")).toEqual("ab=c");
});
it("should leave the empty string alone", () => {
const result = trimTrailingEquals("");
expect(result).toEqual("");
});
});
function mockAccountDataClient(): Mocked<AccountDataClient> {
return {
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
setAccountData: jest.fn().mockResolvedValue({}),
} as unknown as Mocked<AccountDataClient>;
}
+90
View File
@@ -356,6 +356,77 @@ describe("SyncAccumulator", function () {
});
});
it("can handle large numbers of identical receipts", () => {
const testSize = 1000; // Make this big to check performance (e.g. 10 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]).toEqual(newReceipt(1));
});
it("can handle large numbers of receipts for different users and events", () => {
const testSize = 100; // Make this big to check performance (e.g. 1 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
[`$event${ts}:localhost`]: {
[ReceiptType.Read]: {
[`@alice${ts}:localhost`]: { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]["content"]["$event1:localhost"]).toEqual({ "m.read": { "@alice1:localhost": { ts: 1 } } });
expect(Object.keys(events[0]["content"]).length).toEqual(testSize);
});
it("should accumulate threaded read receipts", () => {
const receipt1 = {
type: "m.receipt",
@@ -474,6 +545,25 @@ describe("SyncAccumulator", function () {
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
});
it("should correctly update summary properties to zero", function () {
// When we receive updates of a summary property, the last of which is 0
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}),
);
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 0,
}),
);
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
// Then we give an answer of 0
expect(summary["m.invited_member_count"]).toEqual(0);
});
it("should return correctly adjusted age attributes", () => {
const delta = 1000;
const startingTs = 1000;
+2 -2
View File
@@ -678,14 +678,14 @@ describe("utils", function () {
describe("safeSet", () => {
it("should set a value", () => {
const obj = {};
const obj: Record<string, string> = {};
safeSet(obj, "testProp", "test value");
expect(obj).toEqual({ testProp: "test value" });
});
it.each(["__proto__", "prototype", "constructor"])("should raise an error when setting »%s«", (prop) => {
expect(() => {
safeSet({}, prop, "teset value");
safeSet(<Record<string, string>>{}, prop, "teset value");
}).toThrow("Trying to modify prototype or constructor");
});
});
+128 -1
View File
@@ -25,6 +25,7 @@ import {
CallType,
CallState,
CallParty,
CallDirection,
} from "../../../src/webrtc/call";
import {
MCallAnswer,
@@ -1652,12 +1653,18 @@ describe("Call", function () {
beforeEach(async () => {
jest.useFakeTimers();
jest.spyOn(call, "hangup");
await fakeIncomingCall(client, call, "1");
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
mockPeerConn.iceConnectionState = "disconnected";
mockPeerConn.iceConnectionStateChangeListener!();
jest.spyOn(mockPeerConn, "restartIce");
});
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
jest.advanceTimersByTime(3 * 1000);
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should hang up after being disconnected for 30 seconds", () => {
@@ -1665,6 +1672,20 @@ describe("Call", function () {
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should restart ICE gathering once again after ICE being failed", () => {
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
// @ts-ignore
mockPeerConn.restartIce = null;
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should not hangup if we've managed to re-connect", () => {
mockPeerConn.iceConnectionState = "connected";
mockPeerConn.iceConnectionStateChangeListener!();
@@ -1692,4 +1713,110 @@ describe("Call", function () {
expect(onReplace).toHaveBeenCalled();
});
});
describe("should handle glare in negotiation process", () => {
beforeEach(async () => {
// cut methods not want to test
call.hangup = () => null;
call.isLocalOnHold = () => true;
// @ts-ignore
call.updateRemoteSDPStreamMetadata = jest.fn();
// @ts-ignore
call.getRidOfRTXCodecs = jest.fn();
// @ts-ignore
call.createAnswer = jest.fn().mockResolvedValue({});
// @ts-ignore
call.sendVoipEvent = jest.fn();
});
it("and reject remote offer if not polite and have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do have pending answer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have not a local offer
// @ts-ignore
call.makingOffer = false;
// If we have a setRemoteDescription() answer operation pending, then
// we will be "stable" by the time the next setRemoteDescription() is
// executed, so we count this being readyForOffer when deciding whether to
// ignore the offer.
// @ts-ignore
call.isSettingRemoteAnswerPending = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have no local offer
// @ts-ignore
call.makingOffer = false;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "stable",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and if polite do rollback pending local offer", async () => {
// polite user == CallDirection.Inbound
call.direction = CallDirection.Inbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
});
});
+107 -3
View File
@@ -388,6 +388,10 @@ describe("Group Call", function () {
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
await groupCall.create();
const deviceCallMap = new Map<string, MatrixCall>();
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
});
it("ignores changes, if we can't get user id of opponent", async () => {
@@ -513,8 +517,7 @@ describe("Group Call", function () {
await groupCall.setMicrophoneMuted(false);
expect(groupCall.isMicrophoneMuted()).toEqual(false);
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100);
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
expect(groupCall.isMicrophoneMuted()).toEqual(true);
});
@@ -581,7 +584,15 @@ describe("Group Call", function () {
});
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
const getUserMediaStreamFlush = Promise.resolve("stream");
// @ts-ignore
mockCall.cleint = {
getMediaHandler: {
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
},
};
const mutePromise = groupCall.setMicrophoneMuted(true);
await getUserMediaStreamFlush;
// we should be muted at this point, before the metadata update has been sent
expect(groupCall.isMicrophoneMuted()).toEqual(true);
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
@@ -888,14 +899,34 @@ describe("Group Call", function () {
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when no permission for audio stream", async () => {
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"),
);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when user media stream null", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
// @ts-ignore
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
});
it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
@@ -1625,4 +1656,77 @@ describe("Group Call", function () {
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
});
});
describe("collection stats", () => {
let groupCall: GroupCall;
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => jest.useRealTimers());
beforeEach(async () => {
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
const mockClient = typedMockClient.typed();
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(
mockClient,
room,
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
FAKE_CONF_ID,
);
});
it("should be undefined if not get stats", async () => {
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("should be defined after first access", async () => {
groupCall.getGroupCallStats();
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeDefined();
});
it("with every number should do nothing if no stats exists.", async () => {
groupCall.setGroupCallStatsInterval(0);
// @ts-ignore
let stats = groupCall.stats;
expect(stats).toBeUndefined();
groupCall.setGroupCallStatsInterval(10000);
// @ts-ignore
stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("with number should stop existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(0);
expect(stop).toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
});
it("with number should restart existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(10000);
expect(stop).toHaveBeenCalled();
expect(start).toHaveBeenCalled();
});
});
});
+21 -2
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
const GROUP_CALL_ID = "GROUP_ID";
const LOCAL_USER_ID = "LOCAL_USER_ID";
@@ -67,7 +68,7 @@ describe("GroupCallStats", () => {
jest.useRealTimers();
});
it("starting processing as well without stats collectors", async () => {
it("starting processing stats as well without stats collectors", async () => {
// @ts-ignore
stats.processStats = jest.fn();
stats.start();
@@ -76,12 +77,30 @@ describe("GroupCallStats", () => {
expect(stats.processStats).toHaveBeenCalled();
});
it("not starting processing stats if interval 0", async () => {
const statsDisabled = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, 0);
// @ts-ignore
statsDisabled.processStats = jest.fn();
statsDisabled.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(statsDisabled.processStats).not.toHaveBeenCalled();
});
it("starting processing and calling the collectors", async () => {
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
const collector = stats.getStatsReportGatherer("CALL_ID");
stats.reports.emitSummaryStatsReport = jest.fn();
const summaryStats = {
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 0, muted: 0 },
videoTrackSummary: { count: 0, muted: 0 },
} as SummaryStats;
let processStatsSpy;
if (collector) {
processStatsSpy = jest.spyOn(collector, "processStats");
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
} else {
@@ -26,14 +26,14 @@ describe("MediaSsrcHandler", () => {
handler = new MediaSsrcHandler();
});
describe("should parse description", () => {
it("and build mid ssrc map", () => {
it("and build mid ssrc map", async () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.getSsrcToMidMap("remote")).toEqual(remoteMap);
});
});
describe("should on find mid by ssrc", () => {
it("and return mid if mapping exists.", () => {
it("and return mid if mapping exists.", async () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0");
});
@@ -25,7 +25,7 @@ describe("TrackHandler", () => {
handler = new MediaTrackHandler(pc);
});
describe("should get local tracks", () => {
it("returns video track", () => {
it("returns video track", async () => {
expect(handler.getLocalTracks("video")).toEqual([
{
id: `sender-track-2`,
@@ -34,7 +34,7 @@ describe("TrackHandler", () => {
]);
});
it("returns audio track", () => {
it("returns audio track", async () => {
expect(handler.getLocalTracks("audio")).toEqual([
{
id: `sender-track-1`,
@@ -45,58 +45,72 @@ describe("TrackHandler", () => {
});
describe("should get local track by mid", () => {
it("returns video track", () => {
it("returns video track", async () => {
expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2");
});
it("returns audio track", () => {
it("returns audio track", async () => {
expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1");
});
it("returns undefined if not exists", () => {
it("returns undefined if not exists", async () => {
expect(handler.getLocalTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get remote track by mid", () => {
it("returns video track", () => {
it("returns video track", async () => {
expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2");
});
it("returns audio track", () => {
it("returns audio track", async () => {
expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1");
});
it("returns undefined if not exists", () => {
it("returns undefined if not exists", async () => {
expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get track by id", () => {
it("returns remote track", () => {
it("returns remote track", async () => {
expect(handler.getTackById("receiver-track-2")).toEqual({
id: `receiver-track-2`,
kind: "video",
} as MediaStreamTrack);
});
it("returns local track", () => {
it("returns local track", async () => {
expect(handler.getTackById("sender-track-1")).toEqual({
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack);
});
it("returns undefined if not exists", () => {
it("returns undefined if not exists", async () => {
expect(handler.getTackById("sender-track-3")).toBeUndefined();
});
});
describe("should get simulcast track count", () => {
it("returns 2", () => {
it("returns 2", async () => {
expect(handler.getActiveSimulcastStreams()).toEqual(3);
});
});
describe("should get Transceiver by Track ID", () => {
it("and returns remote Transceiver if exits", async () => {
expect(handler.getTransceiverByTrackId(`receiver-track-1`)?.mid).toEqual("1");
});
it("and returns local Transceiver if exits", async () => {
expect(handler.getTransceiverByTrackId(`sender-track-2`)?.mid).toEqual("2");
});
it("returns undefined if Transceiver not exits", async () => {
expect(handler.getTransceiverByTrackId("22")).toBeUndefined();
});
});
});
const mockTransceiver = (mid: string, kind: "video" | "audio"): RTCRtpTransceiver => {
@@ -62,7 +62,7 @@ describe("MediaTrackStatsHandler", () => {
});
});
describe("should find local video track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", () => {
it("and returns stats if `trackIdentifier` exists in report", async () => {
const report = { trackIdentifier: "2222" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
@@ -75,9 +75,22 @@ describe("MediaTrackStatsHandler", () => {
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", () => {
it("and returns undefined if needed property not existing", async () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
describe("should find a Transceiver by Track id", () => {
it("and returns undefined if Transceiver not existing", async () => {
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(undefined);
expect(statsHandler.findTransceiverByTrackId("12")).toBeUndefined();
});
it("and returns Transceiver if existing", async () => {
const ts = {} as RTCRtpTransceiver;
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(ts);
expect(statsHandler.findTransceiverByTrackId("12")).toEqual(ts);
});
});
});
@@ -87,6 +87,17 @@ describe("StatsReportBuilder", () => {
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
]),
},
jitter: new Map([
["REMOTE_AUDIO_TRACK_ID", 0.1],
["REMOTE_VIDEO_TRACK_ID", 50],
]),
audioConcealment: new Map([
["REMOTE_AUDIO_TRACK_ID", { concealedAudio: 3000, totalAudioDuration: 3000 * 20 }],
]),
totalAudioConcealment: {
concealedAudio: 3000,
totalAudioDuration: (1 / 0.05) * 3000,
},
});
});
});
@@ -99,6 +110,8 @@ describe("StatsReportBuilder", () => {
remoteAudioTrack.setCodec("opus");
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
remoteAudioTrack.setJitter(0.1);
remoteAudioTrack.setAudioConcealment(3000, 3000 * 20);
localVideoTrack.setCodec("v8");
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
@@ -111,5 +124,6 @@ describe("StatsReportBuilder", () => {
remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 });
remoteVideoTrack.setFramerate(60);
remoteVideoTrack.setResolution({ width: 1080, height: 960 });
remoteVideoTrack.setJitter(50);
};
});
@@ -14,7 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "../../../../src/webrtc/stats/statsReport";
import {
ByteSentStatsReport,
ConnectionStatsReport,
StatsReport,
SummaryStatsReport,
} from "../../../../src/webrtc/stats/statsReport";
describe("StatsReportEmitter", () => {
let emitter: StatsReportEmitter;
@@ -45,4 +50,16 @@ describe("StatsReportEmitter", () => {
emitter.emitConnectionStatsReport(report);
});
});
it("should emit and receive SummaryStatsReport", async () => {
const report = {} as SummaryStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.SUMMARY_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitSummaryStatsReport(report);
});
});
});
@@ -34,9 +34,33 @@ describe("StatsReportGatherer", () => {
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockResolvedValue({} as RTCStatsReport);
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(collector.getActive()).toBeTruthy();
});
it("if not active do not calculate stats reports", async () => {
@@ -60,7 +84,27 @@ describe("StatsReportGatherer", () => {
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toBeFalsy();
expect(actual).toEqual({
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
@@ -0,0 +1,448 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
describe("SummaryStatsReporter", () => {
let reporter: SummaryStatsReporter;
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
emitter.emitSummaryStatsReport = jest.fn();
reporter = new SummaryStatsReporter(emitter);
});
describe("build Summary Stats Report", () => {
it("should do nothing if summary list empty", async () => {
reporter.build([]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should trigger new summary report", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 5,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 10,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0.5,
percentageReceivedAudioMedia: 0.5,
percentageReceivedVideoMedia: 0.75,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 4,
percentageConcealedAudio: 0.0375,
});
});
it("as received video Media, although video was not received, but because video muted", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no video Media, because only on video was muted", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 0,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no audio Media, although audio not received and audio muted", async () => {
const summary = [
{
receivedMedia: 100,
receivedAudioMedia: 0,
receivedVideoMedia: 100,
audioTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 0,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("should find max jitter and max packet loss", async () => {
const summary = [
{
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 20,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
},
{
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 40,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 20,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
});
});
it("as received video Media, if no audio track received should count as received Media", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 0,
receivedVideoMedia: 10,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received audio Media, if no video track received should count as received Media", async () => {
const summary = [
{
receivedMedia: 1,
receivedAudioMedia: 22,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no media at all, as received Media", async () => {
const summary = [
{
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
});
});
@@ -129,4 +129,277 @@ describe("TrackStatsReporter", () => {
expect(trackStats.getLoss()).toEqual({ packetsTotal: 280, packetsLost: 80, isDownloadStream: true });
});
});
describe("should set state of a TrackStats", () => {
it("to not alive if Transceiver undefined", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
expect(trackStats.alive).toBeFalsy();
});
it("to not alive if Transceiver has no local track", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
const ts = {
sender: {
track: null,
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
it("to alive if Transceiver remote and track is alive", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
trackStats.alive = false;
const ts = {
receiver: {
track: {
readyState: "live",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
it("to alive if Transceiver local and track is live", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
trackStats.alive = false;
const ts = {
sender: {
track: {
readyState: "live",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
it("to not alive if Transceiver track is ended", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const ts = {
receiver: {
track: {
readyState: "ended",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
it("to not alive and muted if Transceiver track is live and muted", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const ts = {
receiver: {
track: {
readyState: "live",
enabled: false,
muted: true,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
expect(trackStats.muted).toBeTruthy();
});
});
describe("should build Track Summary", () => {
it("and returns empty summary if stats list empty", async () => {
const summary = TrackStatsReporter.buildTrackSummary([]);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary if stats list not empty and ignore local summery", async () => {
const trackStatsList = buildMockTrackStatsList();
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and count muted if alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[5].muted = true;
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and ignore muted if not alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[1].alive = false;
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and build max jitter, packet loss and audio conealment", async () => {
const trackStatsList = buildMockTrackStatsList();
// video remote
trackStatsList[1].setJitter(12);
trackStatsList[4].setJitter(66);
trackStatsList[6].setJitter(1);
trackStatsList[1].setLoss({ packetsLost: 55, packetsTotal: 0, isDownloadStream: true });
trackStatsList[4].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[6].setLoss({ packetsLost: 1, packetsTotal: 0, isDownloadStream: true });
// audio remote
trackStatsList[2].setJitter(1);
trackStatsList[5].setJitter(15);
trackStatsList[2].setLoss({ packetsLost: 5, packetsTotal: 0, isDownloadStream: true });
trackStatsList[5].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[2].setAudioConcealment(220, 2000);
trackStatsList[5].setAudioConcealment(180, 2000);
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 15,
maxPacketLoss: 5,
concealedAudio: 400,
totalAudio: 4000,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 66,
maxPacketLoss: 55,
concealedAudio: 0,
totalAudio: 0,
},
});
});
});
describe("should build jitter value in Track Stats", () => {
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(0);
});
it("and returns track stats with jitter", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(10);
});
it("and returns negative jitter if stats has no jitter value", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
expect(trackStats.getJitter()).toEqual(-1);
});
it("and returns jitter as number", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
expect(trackStats.getJitter()).toEqual(500);
});
});
});
function buildMockTrackStatsList(): MediaTrackStats[] {
const trackStats1 = new MediaTrackStats("1", "local", "video");
trackStats1.muted = false;
trackStats1.alive = true;
const trackStats2 = new MediaTrackStats("1", "remote", "video");
trackStats2.muted = false;
trackStats2.alive = true;
const trackStats3 = new MediaTrackStats("1", "remote", "audio");
trackStats3.muted = false;
trackStats3.alive = true;
const trackStats4 = new MediaTrackStats("1", "local", "audio");
trackStats4.muted = false;
trackStats4.alive = true;
const trackStats5 = new MediaTrackStats("1", "remote", "video");
trackStats5.muted = false;
trackStats5.alive = true;
const trackStats6 = new MediaTrackStats("1", "remote", "audio");
trackStats6.muted = false;
trackStats6.alive = true;
const trackStats7 = new MediaTrackStats("1", "remote", "video");
trackStats7.muted = false;
trackStats7.alive = true;
return [trackStats1, trackStats2, trackStats3, trackStats4, trackStats5, trackStats6, trackStats7];
}
+6
View File
@@ -112,6 +112,12 @@ export interface LoginTokenPostResponse {
login_token: string;
/**
* Expiration in seconds.
*
* @deprecated this is only provided for compatibility with original revision of the MSC.
*/
expires_in: number;
/**
* Expiration in milliseconds.
*/
expires_in_ms: number;
}
+1
View File
@@ -186,6 +186,7 @@ export interface IRelationsRequestOpts {
to?: string;
limit?: number;
dir?: Direction;
recurse?: boolean; // MSC3981 Relations Recursion https://github.com/matrix-org/matrix-spec-proposals/pull/3981
}
export interface IRelationsResponse {
+2 -2
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAuthData } from "../interactive-auth";
import { IAuthDict, IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthData;
auth?: IAuthDict;
};
/**
+3 -1
View File
@@ -25,7 +25,7 @@ export class ReEmitter {
public constructor(private readonly target: EventEmitter) {}
// Map from emitter to event name to re-emitter
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
private reEmitters = new WeakMap<EventEmitter, Map<string, (...args: any[]) => void>>();
public reEmit(source: EventEmitter, eventNames: string[]): void {
let reEmittersByEvent = this.reEmitters.get(source);
@@ -35,6 +35,8 @@ export class ReEmitter {
}
for (const eventName of eventNames) {
if (reEmittersByEvent.has(eventName)) continue;
// We include the source as the last argument for event handlers which may need it,
// such as read receipt listeners on the client class which won't have the context
// of the room.
+187 -98
View File
@@ -30,6 +30,7 @@ import {
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
PushDetails,
} from "./models/event";
import { StubStore } from "./store/stub";
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
@@ -37,7 +38,7 @@ import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
import * as utils from "./utils";
import { replaceParam, QueryDict, sleep, noUnsafeEventProps } from "./utils";
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
@@ -73,7 +74,6 @@ import {
CryptoEventHandlerMap,
fixBackupKey,
ICryptoCallbacks,
IBootstrapCrossSigningOpts,
ICheckOwnCrossSigningTrustOpts,
isCryptoAvailable,
VerificationMethod,
@@ -101,7 +101,6 @@ import { IAuthData, IAuthDict } from "./interactive-auth";
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
import {
CrossSigningKey,
IAddSecretStorageKeyOpts,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
@@ -205,9 +204,14 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
import { CryptoApi } from "./crypto-api";
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
import { DeviceInfoMap } from "./crypto/DeviceList";
import { SecretStorageKeyDescription } from "./secret-storage";
import {
AddSecretStorageKeyOpts,
SecretStorageKeyDescription,
ServerSideSecretStorage,
ServerSideSecretStorageImpl,
} from "./secret-storage";
export type Store = IStore;
@@ -489,11 +493,21 @@ export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
interface ICapabilities {
export interface IMSC3882GetLoginTokenCapability extends ICapability {}
export const UNSTABLE_MSC3882_CAPABILITY = new UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token");
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
[UNSTABLE_MSC3882_CAPABILITY.name]?: IMSC3882GetLoginTokenCapability;
[UNSTABLE_MSC3882_CAPABILITY.altName]?: IMSC3882GetLoginTokenCapability;
}
/* eslint-disable camelcase */
@@ -620,7 +634,7 @@ interface IJoinRequestBody {
interface ITagMetadata {
[key: string]: any;
order: number;
order?: number;
}
interface IMessagesResponse {
@@ -700,7 +714,7 @@ interface IJoinedMembersResponse {
}
export interface IRegisterRequestParams {
auth?: IAuthData;
auth?: IAuthDict;
username?: string;
password?: string;
refresh_token?: boolean;
@@ -1226,7 +1240,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected serverVersionsPromise?: Promise<IServerVersions>;
public cachedCapabilities?: {
capabilities: ICapabilities;
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown;
@@ -1243,6 +1257,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue;
private _secretStorage: ServerSideSecretStorageImpl;
// A manager for determining which invites should be ignored.
public readonly ignoredInvites: IgnoredInvites;
@@ -1408,6 +1424,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
this.ignoredInvites = new IgnoredInvites(this);
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
}
/**
@@ -2045,7 +2062,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves to the capabilities of the homeserver
* @returns Rejects: with an error response.
*/
public getCapabilities(fresh = false): Promise<ICapabilities> {
public getCapabilities(fresh = false): Promise<Capabilities> {
const now = new Date().getTime();
if (this.cachedCapabilities && !fresh) {
@@ -2056,7 +2073,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
type Response = {
capabilities?: ICapabilities;
capabilities?: Capabilities;
};
return this.http
.authedRequest<Response>(Method.Get, "/capabilities")
@@ -2210,13 +2227,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
this.cryptoBackend = rustCrypto;
// attach the event listeners needed by RustCrypto
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
}
/**
* Access the server-side secret storage API for this client.
*/
public get secretStorage(): ServerSideSecretStorage {
return this._secretStorage;
}
/**
* Access the crypto API for this client.
*
@@ -2269,7 +2293,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param userIds - The users to fetch.
* @param forceDownload - Always download the keys even if cached.
*
* @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}
* @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo`
*
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
if (!this.crypto) {
@@ -2284,6 +2310,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param userId - the user to list keys for.
*
* @returns list of devices
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public getStoredDevicesForUser(userId: string): DeviceInfo[] {
if (!this.crypto) {
@@ -2299,6 +2326,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param deviceId - unique identifier for the device
*
* @returns device or null
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
if (!this.crypto) {
@@ -2409,10 +2437,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns the VerificationRequest that is in progress, if any
*/
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.findVerificationRequestDMInProgress(roomId);
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
}
/**
@@ -2463,11 +2491,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.crypto.beginKeyVerification(method, userId, deviceId);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
*/
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.checkSecretStorageKey(key, info);
return this.secretStorage.checkKey(key, info);
}
/**
@@ -2543,14 +2571,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Get the user's cross-signing key ID.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* Get the ID of one of the user's cross-signing keys
*
* @param type - The type of key to get the ID of. One of
* "master", "self_signing", or "user_signing". Defaults to "master".
*
* @returns the key ID
* @deprecated prefer {@link CryptoApi#getCrossSigningKeyId}
*/
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
if (!this.crypto) {
@@ -2569,10 +2596,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns the cross signing information for the user.
*/
public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getStoredCrossSigningForUser(userId);
return this.cryptoBackend.getStoredCrossSigningForUser(userId);
}
/**
@@ -2596,12 +2623,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param userId - The ID of the user whose devices is to be checked.
* @param deviceId - The ID of the device to check
*
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
if (!this.cryptoBackend) {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
return this.crypto.checkDeviceTrust(userId, deviceId);
}
/**
@@ -2705,12 +2734,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* bootstrapCrossSigning() completes successfully, this function should
* return true.
* @returns True if cross-signing is ready to be used on this device
* @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}:
*/
public isCrossSigningReady(): Promise<boolean> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isCrossSigningReady();
return this.cryptoBackend.isCrossSigningReady();
}
/**
@@ -2720,15 +2750,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* This function:
* - creates new cross-signing keys if they are not found locally cached nor in
* secret storage (if it has been setup)
* secret storage (if it has been set up)
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}.
*/
public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
if (!this.crypto) {
public bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.bootstrapCrossSigning(opts);
return this.cryptoBackend.bootstrapCrossSigning(opts);
}
/**
@@ -2739,24 +2769,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Default: true
*
* @returns True if trusting cross-signed devices
*
* @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}.
*/
public getCryptoTrustCrossSignedDevices(): boolean {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getCryptoTrustCrossSignedDevices();
return this.cryptoBackend.getTrustCrossSignedDevices();
}
/**
* See getCryptoTrustCrossSignedDevices
*
* @param val - True to trust cross-signed devices
*
* @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}.
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
this.crypto.setCryptoTrustCrossSignedDevices(val);
this.cryptoBackend.setTrustCrossSignedDevices(val);
}
/**
@@ -2813,15 +2847,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* bootstrapSecretStorage() completes successfully, this function should
* return true.
*
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
*
* @returns True if secret storage is ready to be used on this device
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
*/
public isSecretStorageReady(): Promise<boolean> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isSecretStorageReady();
return this.cryptoBackend.isSecretStorageReady();
}
/**
@@ -2858,16 +2891,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns An object with:
* keyId: the ID of the key
* keyInfo: details about the key (iv, mac, passphrase)
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
*/
public addSecretStorageKey(
algorithm: string,
opts: IAddSecretStorageKeyOpts,
opts: AddSecretStorageKeyOpts,
keyName?: string,
): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.addSecretStorageKey(algorithm, opts, keyName);
return this.secretStorage.addKey(algorithm, opts, keyName);
}
/**
@@ -2878,12 +2910,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns Whether we have the key.
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
*/
public hasSecretStorageKey(keyId?: string): Promise<boolean> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.hasSecretStorageKey(keyId);
return this.secretStorage.hasKey(keyId);
}
/**
@@ -2895,12 +2926,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param secret - The secret contents.
* @param keys - The IDs of the keys to use to encrypt the secret or null/undefined
* to use the default (will throw if no default key is set).
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
*/
public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.storeSecret(name, secret, keys);
return this.secretStorage.store(name, secret, keys);
}
/**
@@ -2911,12 +2941,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param name - the name of the secret
*
* @returns the contents of the secret
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
*/
public getSecret(name: string): Promise<string | undefined> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getSecret(name);
return this.secretStorage.get(name);
}
/**
@@ -2928,12 +2957,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
*/
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isSecretStored(name);
return this.secretStorage.isStored(name);
}
/**
@@ -2959,12 +2987,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
*
* @returns The default key ID or null if no default key ID is set
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
*/
public getDefaultSecretStorageKeyId(): Promise<string | null> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getDefaultSecretStorageKeyId();
return this.secretStorage.getDefaultKeyId();
}
/**
@@ -2973,12 +3000,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
*
* @param keyId - The new default key ID
*
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
*/
public setDefaultSecretStorageKeyId(keyId: string): Promise<void> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.setDefaultSecretStorageKeyId(keyId);
return this.secretStorage.setDefaultKeyId(keyId);
}
/**
@@ -2991,6 +3017,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param privateKey - The private key
* @param expectedPublicKey - The public key
* @returns true if the key matches, otherwise false
*
* @deprecated The use of asymmetric keys for SSSS is deprecated.
* Use {@link SecretStorage.ServerSideSecretStorage#checkKey} for symmetric keys.
*/
public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
if (!this.crypto) {
@@ -3287,7 +3316,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
await this.crypto.backupManager.prepareKeyBackupVersion(password);
if (opts.secureSecretStorage) {
await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
logger.info("Key backup private key stored in secret storage");
}
@@ -3307,7 +3336,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* trusted key
*/
public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> {
return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1"));
}
/**
@@ -3572,14 +3601,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
const storedKey = await this.getSecret("m.megolm_backup.v1");
const storedKey = await this.secretStorage.get("m.megolm_backup.v1");
// ensure that the key is in the right format. If not, fix the key and
// store the fixed version
const fixedKey = fixBackupKey(storedKey);
if (fixedKey) {
const keys = await this.crypto.getSecretStorageKey();
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
const keys = await this.secretStorage.getKey();
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
}
const privKey = decodeBase64(fixedKey || storedKey!);
@@ -4158,7 +4187,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object
* @returns Rejects: with an error response.
*/
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata = {}): Promise<{}> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
$userId: this.credentials.userId!,
$roomId: roomId,
@@ -5365,11 +5394,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null {
if (!event.getPushActions() || forceRecalculate) {
event.setPushActions(this.pushProcessor.actionsForEvent(event));
const { actions, rule } = this.pushProcessor.actionsAndRuleForEvent(event);
event.setPushDetails(actions, rule);
}
return event.getPushActions();
}
/**
* Obtain a dict of actions which should be performed for this event according
* to the push rules for this user. Caches the dict on the event.
* @param event - The event to get push actions for.
* @param forceRecalculate - forces to recalculate actions for an event
* Useful when an event just got decrypted
* @returns A dict of actions to perform.
*/
public getPushDetailsForEvent(event: MatrixEvent, forceRecalculate = false): PushDetails | null {
if (!event.getPushDetails() || forceRecalculate) {
const { actions, rule } = this.pushProcessor.actionsAndRuleForEvent(event);
event.setPushDetails(actions, rule);
}
return event.getPushDetails();
}
/**
* @param info - The kind of info to set (e.g. 'avatar_url')
* @param data - The JSON object to set.
@@ -5692,6 +5738,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return undefined;
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
if (Thread.hasServerSideSupport) {
if (Thread.hasServerSideFwdPaginationSupport) {
if (!timelineSet.thread) {
@@ -5704,14 +5751,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: res.end },
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
);
const events = [
// Order events from most recent to oldest (reverse-chronological).
@@ -5759,7 +5806,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const eventsNewer: IEvent[] = [];
let nextBatch: Optional<string> = res.end;
@@ -5769,7 +5816,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: nextBatch },
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
);
nextBatch = resNewer.next_batch ?? null;
eventsNewer.push(...resNewer.chunk);
@@ -5840,12 +5887,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
);
event = res.chunk?.[0];
} else if (timelineSet.thread && Thread.hasServerSideSupport) {
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
const res = await this.fetchRelations(
timelineSet.room.roomId,
timelineSet.thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, limit: 1 },
{ dir: Direction.Backward, limit: 1, recurse: recurse || undefined },
);
event = res.chunk?.[0];
} else {
@@ -6044,7 +6092,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
for (let i = 0; i < res.notifications.length; i++) {
const notification = res.notifications[i];
const event = this.getEventMapper()(notification.event);
event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions));
// @TODO(kerrya) reprocessing every notification is ugly
// remove if we get server MSC3994 support
this.getPushDetailsForEvent(event, true);
event.event.room_id = notification.room_id; // XXX: gutwrenching
matrixEvents[i] = event;
}
@@ -6116,10 +6168,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("Unknown room " + eventTimeline.getRoomId());
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
dir,
limit: opts.limit,
from: token ?? undefined,
recurse: recurse || undefined,
})
.then(async (res) => {
const mapper = this.getEventMapper();
@@ -7462,7 +7516,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Set the identity server URL of this client
* @param url - New identity server URL
*/
public setIdentityServerUrl(url: string): void {
public setIdentityServerUrl(url?: string): void {
this.idBaseUrl = utils.ensureNoTrailingSlash(url);
this.http.setIdBaseUrl(this.idBaseUrl);
}
@@ -7809,15 +7863,33 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: On success, the token response
* or UIA auth data.
*/
public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
public async requestLoginToken(auth?: IAuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
// use capabilities to determine which revision of the MSC is being used
const capabilities = await this.getCapabilities();
// use r1 endpoint if capability is exposed otherwise use old r0 endpoint
const endpoint = UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities)
? "/org.matrix.msc3882/login/get_token" // r1 endpoint
: "/org.matrix.msc3882/login/token"; // r0 endpoint
const body: UIARequest<{}> = { auth };
return this.http.authedRequest(
const res = await this.http.authedRequest<UIAResponse<LoginTokenPostResponse>>(
Method.Post,
"/org.matrix.msc3882/login/token",
endpoint,
undefined, // no query params
body,
{ prefix: ClientPrefix.Unstable },
);
// the representation of expires_in changed from revision 0 to revision 1 so we populate
if ("login_token" in res) {
if (typeof res.expires_in_ms === "number") {
res.expires_in = Math.floor(res.expires_in_ms / 1000);
} else if (typeof res.expires_in === "number") {
res.expires_in_ms = res.expires_in * 1000;
}
}
return res;
}
/**
@@ -7890,6 +7962,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
}
if (this.canSupport.get(Feature.RelationsRecursion) === ServerSupport.Unstable) {
params = replaceParam("recurse", "org.matrix.msc3981.recurse", params);
}
const queryString = utils.encodeParams(params);
let templatedUrl = "/rooms/$roomId/relations/$eventId";
@@ -8170,7 +8245,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
// eslint-disable-line camelcase
// TODO: deprecate this or resolveRoomAlias
const path = utils.encodeUri("/directory/room/$alias", {
$alias: alias,
});
@@ -8180,10 +8254,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @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[] }> {
// TODO: deprecate this or getRoomIdForAlias
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
return this.http.request(Method.Get, path);
}
@@ -8555,6 +8629,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Post, path, undefined, pusher);
}
/**
* Removes an existing pusher
* @param pushKey - pushkey of pusher to remove
* @param appId - app_id of pusher to remove
* @returns Promise which resolves: Empty json object on success
* @returns Rejects: with an error response.
*/
public removePusher(pushKey: string, appId: string): Promise<{}> {
const path = "/pushers/set";
const body = {
pushkey: pushKey,
app_id: appId,
kind: null, // marks pusher for removal
};
return this.http.authedRequest(Method.Post, path, undefined, body);
}
/**
* Persists local notification settings
* @returns Promise which resolves: an empty object
@@ -8749,8 +8840,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
for (const [userId, deviceId] of devices) {
const query = queries[userId] || {};
queries[userId] = query;
query[deviceId] = keyAlgorithm;
safeSet(queries, userId, query);
safeSet(query, deviceId, keyAlgorithm);
}
const content: IClaimKeysRequest = { one_time_keys: queries };
if (timeout) {
@@ -8777,7 +8868,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Get, "/keys/changes", qps);
}
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
public uploadDeviceSigningKeys(auth?: IAuthDict, keys?: CrossSigningKeys): Promise<{}> {
// API returns empty object
const data = Object.assign({}, keys);
if (auth) Object.assign(data, { auth });
@@ -8926,7 +9017,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param identityAccessToken - The `access_token` field of the Identity
* Server `/account/register` response (see {@link registerWithIdentityServer}).
*
* @returns Promise which resolves: Object, currently with no parameters.
* @returns Promise which resolves: Object, containing success boolean.
* @returns Rejects: with an error response.
* @throws Error if No identity server is set
*/
@@ -8935,8 +9026,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
clientSecret: string,
msisdnToken: string,
identityAccessToken: string,
): Promise<any> {
// TODO: Types
): Promise<{ success: boolean }> {
const params = {
sid: sid,
client_secret: clientSecret,
@@ -8967,7 +9057,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* This must be the same value submitted in the requestToken call.
* @param msisdnToken - The MSISDN token, as enetered by the user.
*
* @returns Promise which resolves: Object, currently with no parameters.
* @returns Promise which resolves: Object, containing success boolean.
* @returns Rejects: with an error response.
*/
public submitMsisdnTokenOtherUrl(
@@ -8975,8 +9065,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
sid: string,
clientSecret: string,
msisdnToken: string,
): Promise<any> {
// TODO: Types
): Promise<{ success: boolean }> {
const params = {
sid: sid,
client_secret: clientSecret,
+30 -31
View File
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IToDeviceEvent } from "../sync-accumulator";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi } from "../crypto-api";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { IEventDecryptionResult } from "../@types/crypto";
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
/**
* Common interface for the crypto implementations
@@ -51,16 +52,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Get the verification level for a given device
*
* TODO: define this better
*
* @param userId - user to be checked
* @param deviceId - device to be checked
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Encrypt an event according to the configuration of the room.
*
@@ -87,6 +78,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @param event - event to be checked
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
}
/** The methods which crypto implementations should expose to the Sync api */
@@ -106,32 +117,20 @@ export interface SyncCryptoCallbacks {
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop whenever there are incoming to-device messages.
*
* The implementation may preprocess the received messages (eg, decrypt them) and return an
* updated list of messages for dispatch to the rest of the system.
*
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
* messages, rather than the results of any decryption attempts.
* Called by the /sync loop when one time key counts and unused fallback key details are received.
*
* @param oneTimeKeysCounts - the received one time key counts
* @returns A list of preprocessed to-device messages.
* @param unusedFallbackKeys - the received unused fallback keys
*/
preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void>;
processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void>;
/**
* Called by the /sync loop whenever there are incoming to-device messages.
* Handle the notification from /sync that device lists have
* been changed.
*
* The implementation may preprocess the received messages (eg, decrypt them) and return an
* updated list of messages for dispatch to the rest of the system.
*
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
* messages, rather than the results of any decryption attempts.
*
* @param unusedFallbackKeys - the received unused fallback keys
* @returns A list of preprocessed to-device messages.
* @param deviceLists - device_lists field from /sync
*/
preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void>;
processDeviceLists(deviceLists: IDeviceLists): Promise<void>;
/**
* Called by the /sync loop whenever an m.room.encryption event is received.
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "./utils";
import { encodeParams } from "./utils";
/**
* Get the HTTP URL for an MXC URI.
@@ -74,6 +74,6 @@ export function getHttpUriForMxc(
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
}
+187
View File
@@ -16,6 +16,15 @@ limitations under the License.
import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
/** Types of cross-signing key */
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
/**
* Public interface to the cryptography parts of the js-sdk
@@ -72,4 +81,182 @@ export interface CryptoApi {
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
/**
* Get the device information for the given list of users.
*
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
* cached device data is returned.
*
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
* a `/keys/query` request is made to the server to retrieve these devices.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
getUserDeviceInfo(userIds: string[], downloadUncached?: boolean): Promise<DeviceMap>;
/**
* Set whether to trust other user's signatures of their devices.
*
* If false, devices will only be considered 'verified' if we have
* verified that device individually (effectively disabling cross-signing).
*
* `true` by default.
*
* @param val - the new value
*/
setTrustCrossSignedDevices(val: boolean): void;
/**
* Return whether we trust other user's signatures of their devices.
*
* @see {@link CryptoApi#setTrustCrossSignedDevices}
*
* @returns `true` if we trust cross-signed devices, otherwise `false`.
*/
getTrustCrossSignedDevices(): boolean;
/**
* Get the verification status of a given device.
*
* @param userId - The ID of the user whose device is to be checked.
* @param deviceId - The ID of the device to check
*
* @returns Verification status of the device, or `null` if the device is not known
*/
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
/**
* Checks whether cross signing:
* - is enabled on this account and trusted by this device
* - has private keys either cached locally or stored in secret storage
*
* If this function returns false, bootstrapCrossSigning() can be used
* to fix things such that it returns true. That is to say, after
* bootstrapCrossSigning() completes successfully, this function should
* return true.
*
* @returns True if cross-signing is ready to be used on this device
*/
isCrossSigningReady(): Promise<boolean>;
/**
* Get the ID of one of the user's cross-signing keys.
*
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigngingKey.SelfSigning`,
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
*
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
*/
getCrossSigningKeyId(type?: CrossSigningKey): Promise<string | null>;
/**
* Bootstrap cross-signing by creating keys if needed.
*
* If everything is already set up, then no changes are made, so this is safe to run to ensure
* cross-signing is ready for use.
*
* This function:
* - creates new cross-signing keys if they are not found locally cached nor in
* secret storage (if it has been set up)
* - publishes the public keys to the server if they are not already published
* - stores the private keys in secret storage if secret storage is set up.
*
* @param opts - options object
*/
bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void>;
/**
* Checks whether secret storage:
* - is enabled on this account
* - is storing cross-signing private keys
* - is storing session backup key (if enabled)
*
* If this function returns false, bootstrapSecretStorage() can be used
* to fix things such that it returns true. That is to say, after
* bootstrapSecretStorage() completes successfully, this function should
* return true.
*
* @returns True if secret storage is ready to be used on this device
*/
isSecretStorageReady(): Promise<boolean>;
}
/**
* Options object for `CryptoApi.bootstrapCrossSigning`.
*/
export interface BootstrapCrossSigningOpts {
/** Optional. Reset the cross-signing keys even if keys already exist. */
setupNewCrossSigning?: boolean;
/**
* An application callback to collect the authentication data for uploading the keys. If not given, the keys
* will not be uploaded to the server (which seems like a bad thing?).
*/
authUploadDeviceSigningKeys?: UIAuthCallback<void>;
}
export class DeviceVerificationStatus {
/**
* True if this device has been signed by its owner (and that signature verified).
*
* This doesn't necessarily mean that we have verified the device, since we may not have verified the
* owner's cross-signing key.
*/
public readonly signedByOwner: boolean;
/**
* True if this device has been verified via cross signing.
*
* This does *not* take into account `trustCrossSignedDevices`.
*/
public readonly crossSigningVerified: boolean;
/**
* TODO: tofu magic wtf does this do?
*/
public readonly tofu: boolean;
/**
* True if the device has been marked as locally verified.
*/
public readonly localVerified: boolean;
/**
* True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
*/
private readonly trustCrossSignedDevices: boolean;
public constructor(
opts: Partial<DeviceVerificationStatus> & {
/**
* True if cross-signed devices should be considered verified for {@link DeviceVerificationStatus#isVerified}.
*/
trustCrossSignedDevices?: boolean;
},
) {
this.signedByOwner = opts.signedByOwner ?? false;
this.crossSigningVerified = opts.crossSigningVerified ?? false;
this.tofu = opts.tofu ?? false;
this.localVerified = opts.localVerified ?? false;
this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false;
}
/**
* Check if we should consider this device "verified".
*
* A device is "verified" if either:
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
* cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
*
* @returns true if this device is verified via any means.
*/
public isVerified(): boolean {
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
}
}
+21 -19
View File
@@ -25,13 +25,13 @@ import { logger } from "../logger";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
import { DeviceInfo } from "./deviceinfo";
import { SecretStorage } from "./SecretStorage";
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
import { OlmDevice } from "./OlmDevice";
import { ICryptoCallbacks } from ".";
import { ISignatures } from "../@types/signed";
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
import { SecretStorageKeyDescription } from "../secret-storage";
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
import { DeviceVerificationStatus } from "../crypto-api";
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
@@ -164,7 +164,7 @@ export class CrossSigningInfo {
* key
*/
public async isStoredInSecretStorage(
secretStorage: SecretStorage<MatrixClient | undefined>,
secretStorage: ServerSideSecretStorage,
): Promise<Record<string, object> | null> {
// check what SSSS keys have encrypted the master key (if any)
const stored = (await secretStorage.isStored("m.cross_signing.master")) || {};
@@ -192,7 +192,7 @@ export class CrossSigningInfo {
*/
public static async storeInSecretStorage(
keys: Map<string, Uint8Array>,
secretStorage: SecretStorage<undefined>,
secretStorage: ServerSideSecretStorage,
): Promise<void> {
for (const [type, privateKey] of keys) {
const encodedKey = encodeBase64(privateKey);
@@ -209,7 +209,10 @@ export class CrossSigningInfo {
* @param secretStorage - The secret store using account data
* @returns The private key
*/
public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> {
public static async getFromSecretStorage(
type: string,
secretStorage: ServerSideSecretStorage,
): Promise<Uint8Array | null> {
const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
if (!encodedKey) {
return null;
@@ -626,15 +629,20 @@ export class UserTrustLevel {
}
/**
* Represents the ways in which we trust a device
* Represents the ways in which we trust a device.
*
* @deprecated Use {@link DeviceVerificationStatus}.
*/
export class DeviceTrustLevel {
export class DeviceTrustLevel extends DeviceVerificationStatus {
public constructor(
public readonly crossSigningVerified: boolean,
public readonly tofu: boolean,
private readonly localVerified: boolean,
private readonly trustCrossSignedDevices: boolean,
) {}
crossSigningVerified: boolean,
tofu: boolean,
localVerified: boolean,
trustCrossSignedDevices: boolean,
signedByOwner = false,
) {
super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner });
}
public static fromUserTrustLevel(
userTrustLevel: UserTrustLevel,
@@ -646,16 +654,10 @@ export class DeviceTrustLevel {
userTrustLevel.isTofu(),
localVerified,
trustCrossSignedDevices,
true,
);
}
/**
* @returns true if this device is verified via any means
*/
public isVerified(): boolean {
return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified()));
}
/**
* @returns true if this device is verified via cross signing
*/
+11 -11
View File
@@ -15,11 +15,11 @@ limitations under the License.
*/
import { logger } from "../logger";
import { IContent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { Method, ClientPrefix } from "../http-api";
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
import { Crypto, ICryptoCallbacks } from "./index";
import {
ClientEvent,
ClientEventHandlerMap,
@@ -30,11 +30,11 @@ import {
} from "../client";
import { IKeyBackupInfo } from "./keybackup";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IAccountDataClient } from "./SecretStorage";
import { SecretStorageKeyDescription } from "../secret-storage";
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
import { BootstrapCrossSigningOpts } from "../crypto-api";
interface ICrossSigningKeys {
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
}
@@ -238,7 +238,7 @@ export class EncryptionSetupOperation {
*/
class AccountDataClientAdapter
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
implements IAccountDataClient
implements AccountDataClient
{
//
public readonly values = new Map<string, MatrixEvent>();
@@ -253,21 +253,21 @@ class AccountDataClientAdapter
/**
* @returns the content of the account data
*/
public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T> {
return Promise.resolve(this.getAccountData(type) as T);
public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T | null> {
return Promise.resolve(this.getAccountData(type));
}
/**
* @returns the content of the account data
*/
public getAccountData(type: string): IContent | null {
public getAccountData<T extends { [k: string]: any }>(type: string): T | null {
const modifiedValue = this.values.get(type);
if (modifiedValue) {
return modifiedValue;
return modifiedValue as unknown as T;
}
const existingValue = this.existingValues.get(type);
if (existingValue) {
return existingValue.getContent();
return existingValue.getContent<T>();
}
return null;
}
+237
View File
@@ -0,0 +1,237 @@
/*
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { v4 as uuidv4 } from "uuid";
import { MatrixClient } from "../client";
import { ICryptoCallbacks, IEncryptedContent } from "./index";
import { defer, IDeferred } from "../utils";
import { ToDeviceMessageId } from "../@types/event";
import { logger } from "../logger";
import { MatrixEvent } from "../models/event";
import * as olmlib from "./olmlib";
export interface ISecretRequest {
requestId: string;
promise: Promise<string>;
cancel: (reason: string) => void;
}
interface ISecretRequestInternal {
name: string;
devices: string[];
deferred: IDeferred<string>;
}
export class SecretSharing {
private requests = new Map<string, ISecretRequestInternal>();
public constructor(private readonly baseApis: MatrixClient, private readonly cryptoCallbacks: ICryptoCallbacks) {}
/**
* Request a secret from another device
*
* @param name - the name of the secret to request
* @param devices - the devices to request the secret from
*/
public request(name: string, devices: string[]): ISecretRequest {
const requestId = this.baseApis.makeTxnId();
const deferred = defer<string>();
this.requests.set(requestId, { name, devices, deferred });
const cancel = (reason: string): void => {
// send cancellation event
const cancelData = {
action: "request_cancellation",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
};
const toDevice: Map<string, typeof cancelData> = new Map();
for (const device of devices) {
toDevice.set(device, cancelData);
}
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
// and reject the promise so that anyone waiting on it will be
// notified
deferred.reject(new Error(reason || "Cancelled"));
};
// send request to devices
const requestData = {
name,
action: "request",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
[ToDeviceMessageId]: uuidv4(),
};
const toDevice: Map<string, typeof requestData> = new Map();
for (const device of devices) {
toDevice.set(device, requestData);
}
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
return {
requestId,
promise: deferred.promise,
cancel,
};
}
public async onRequestReceived(event: MatrixEvent): Promise<void> {
const sender = event.getSender();
const content = event.getContent();
if (
sender !== this.baseApis.getUserId() ||
!(content.name && content.action && content.requesting_device_id && content.request_id)
) {
// ignore requests from anyone else, for now
return;
}
const deviceId = content.requesting_device_id;
// check if it's a cancel
if (content.action === "request_cancellation") {
/*
Looks like we intended to emit events when we got cancelations, but
we never put anything in the _incomingRequests object, and the request
itself doesn't use events anyway so if we were to wire up cancellations,
they probably ought to use the same callback interface. I'm leaving them
disabled for now while converting this file to typescript.
if (this._incomingRequests[deviceId]
&& this._incomingRequests[deviceId][content.request_id]) {
logger.info(
"received request cancellation for secret (" + sender +
", " + deviceId + ", " + content.request_id + ")",
);
this.baseApis.emit("crypto.secrets.requestCancelled", {
user_id: sender,
device_id: deviceId,
request_id: content.request_id,
});
}
*/
} else if (content.action === "request") {
if (deviceId === this.baseApis.deviceId) {
// no point in trying to send ourself the secret
return;
}
// check if we have the secret
logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
if (!this.cryptoCallbacks.onSecretRequested) {
return;
}
const secret = await this.cryptoCallbacks.onSecretRequested(
sender,
deviceId,
content.request_id,
content.name,
this.baseApis.checkDeviceTrust(sender, deviceId),
);
if (secret) {
logger.info(`Preparing ${content.name} secret for ${deviceId}`);
const payload = {
type: "m.secret.send",
content: {
request_id: content.request_id,
secret: secret,
},
};
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
await olmlib.ensureOlmSessionsForDevices(
this.baseApis.crypto!.olmDevice,
this.baseApis,
new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
);
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.baseApis.getUserId()!,
this.baseApis.deviceId!,
this.baseApis.crypto!.olmDevice,
sender,
this.baseApis.getStoredDevice(sender, deviceId)!,
payload,
);
const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
logger.info(`Sending ${content.name} secret for ${deviceId}`);
this.baseApis.sendToDevice("m.room.encrypted", contentMap);
} else {
logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
}
}
}
public onSecretReceived(event: MatrixEvent): void {
if (event.getSender() !== this.baseApis.getUserId()) {
// we shouldn't be receiving secrets from anyone else, so ignore
// because someone could be trying to send us bogus data
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("secret event not properly encrypted");
return;
}
const content = event.getContent();
const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey() || "",
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
logger.log("got secret share for request", content.request_id);
const requestControl = this.requests.get(content.request_id);
if (requestControl) {
// make sure that the device that sent it is one of the devices that
// we requested from
const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey()!,
);
if (!deviceInfo) {
logger.log("secret share from unknown device with key", event.getSenderKey());
return;
}
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
return;
}
// unsure that the sender is trusted. In theory, this check is
// unnecessary since we only accept secret shares from devices that
// we requested from, but it doesn't hurt.
const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
if (!deviceTrust.isVerified()) {
logger.log("secret share from unverified device");
return;
}
logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
requestControl.deferred.resolve(content.secret);
}
}
}
+59 -502
View File
@@ -14,570 +14,127 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { v4 as uuidv4 } from "uuid";
import { ICryptoCallbacks } from ".";
import { MatrixEvent } from "../models/event";
import { MatrixClient } from "../client";
import {
SecretStorageKeyDescription,
SecretStorageKeyTuple,
SecretStorageKeyObject,
AddSecretStorageKeyOpts,
AccountDataClient,
ServerSideSecretStorage,
ServerSideSecretStorageImpl,
} from "../secret-storage";
import { ISecretRequest, SecretSharing } from "./SecretSharing";
import { logger } from "../logger";
import * as olmlib from "./olmlib";
import { randomString } from "../randomstring";
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
import { ICryptoCallbacks, IEncryptedContent } from ".";
import { IContent, MatrixEvent } from "../models/event";
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client";
import { IAddSecretStorageKeyOpts } from "./api";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { defer, IDeferred } from "../utils";
import { ToDeviceMessageId } from "../@types/event";
import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage";
/* re-exports for backwards compatibility */
export type {
AccountDataClient as IAccountDataClient,
SecretStorageKeyTuple,
SecretStorageKeyObject,
SECRET_STORAGE_ALGORITHM_V1_AES,
} from "../secret-storage";
export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
// Some of the key functions use a tuple and some use an object...
export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription];
export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription };
export interface ISecretRequest {
requestId: string;
promise: Promise<string>;
cancel: (reason: string) => void;
}
export interface IAccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> {
// Subset of MatrixClient (which also uses any for the event content)
getAccountDataFromServer: <T extends { [k: string]: any }>(eventType: string) => Promise<T>;
getAccountData: (eventType: string) => IContent | null;
setAccountData: (eventType: string, content: any) => Promise<{}>;
}
interface ISecretRequestInternal {
name: string;
devices: string[];
deferred: IDeferred<string>;
}
interface IDecryptors {
encrypt: (plaintext: string) => Promise<IEncryptedPayload>;
decrypt: (ciphertext: IEncryptedPayload) => Promise<string>;
}
interface ISecretInfo {
encrypted: {
[keyId: string]: IEncryptedPayload;
};
}
export type { ISecretRequest } from "./SecretSharing";
/**
* Implements Secure Secret Storage and Sharing (MSC1946)
*
* @deprecated This is just a backwards-compatibility hack which will be removed soon.
* Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`.
*/
export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> {
private requests = new Map<string, ISecretRequestInternal>();
export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> implements ServerSideSecretStorage {
private readonly storageImpl: ServerSideSecretStorageImpl;
private readonly sharingImpl: SecretSharing;
// In it's pure javascript days, this was relying on some proper Javascript-style
// In its pure javascript days, this was relying on some proper Javascript-style
// type-abuse where sometimes we'd pass in a fake client object with just the account
// data methods implemented, which is all this class needs unless you use the secret
// sharing code, so it was fine. As a low-touch TypeScript migration, this now has
// sharing code, so it was fine. As a low-touch TypeScript migration, we added
// an extra, optional param for a real matrix client, so you can not pass it as long
// as you don't request any secrets.
// A better solution would probably be to split this class up into secret storage and
// secret sharing which are really two separate things, even though they share an MSC.
public constructor(
private readonly accountDataAdapter: IAccountDataClient,
private readonly cryptoCallbacks: ICryptoCallbacks,
private readonly baseApis: B,
) {}
//
// Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy
// Crypto impl that exposes it.
public constructor(accountDataAdapter: AccountDataClient, cryptoCallbacks: ICryptoCallbacks, baseApis: B) {
this.storageImpl = new ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks);
this.sharingImpl = new SecretSharing(baseApis as MatrixClient, cryptoCallbacks);
}
public async getDefaultKeyId(): Promise<string | null> {
const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>(
"m.secret_storage.default_key",
);
if (!defaultKey) return null;
return defaultKey.key;
public getDefaultKeyId(): Promise<string | null> {
return this.storageImpl.getDefaultKeyId();
}
public setDefaultKeyId(keyId: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const listener = (ev: MatrixEvent): void => {
if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
resolve();
}
};
this.accountDataAdapter.on(ClientEvent.AccountData, listener);
this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => {
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
reject(e);
});
});
return this.storageImpl.setDefaultKeyId(keyId);
}
/**
* Add a key for encrypting secrets.
*
* @param algorithm - the algorithm used by the key.
* @param opts - the options for the algorithm. The properties used
* depend on the algorithm given.
* @param keyId - the ID of the key. If not given, a random
* ID will be generated.
*
* @returns An object with:
* keyId: the ID of the key
* keyInfo: details about the key (iv, mac, passphrase)
*/
public async addKey(
public addKey(
algorithm: string,
opts: IAddSecretStorageKeyOpts = {},
opts: AddSecretStorageKeyOpts = {},
keyId?: string,
): Promise<SecretStorageKeyObject> {
if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
throw new Error(`Unknown key algorithm ${algorithm}`);
}
const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1;
if (opts.name) {
keyInfo.name = opts.name;
}
if (opts.passphrase) {
keyInfo.passphrase = opts.passphrase;
}
if (opts.key) {
const { iv, mac } = await calculateKeyCheck(opts.key);
keyInfo.iv = iv;
keyInfo.mac = mac;
}
if (!keyId) {
do {
keyId = randomString(32);
} while (
await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
`m.secret_storage.key.${keyId}`,
)
);
}
await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
return {
keyId,
keyInfo,
};
return this.storageImpl.addKey(algorithm, opts, keyId);
}
/**
* Get the key information for a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns If the key was found, the return value is an array of
* the form [keyId, keyInfo]. Otherwise, null is returned.
* XXX: why is this an array when addKey returns an object?
*/
public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
if (!keyId) {
keyId = await this.getDefaultKeyId();
}
if (!keyId) {
return null;
}
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
"m.secret_storage.key." + keyId,
);
return keyInfo ? [keyId, keyInfo] : null;
public getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
return this.storageImpl.getKey(keyId);
}
/**
* Check whether we have a key with a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns Whether we have the key.
*/
public async hasKey(keyId?: string): Promise<boolean> {
return Boolean(await this.getKey(keyId));
public hasKey(keyId?: string): Promise<boolean> {
return this.storageImpl.hasKey(keyId);
}
/**
* Check whether a key matches what we expect based on the key info
*
* @param key - the key to check
* @param info - the key info
*
* @returns whether or not the key matches
*/
public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (info.mac) {
const { mac } = await calculateKeyCheck(key, info.iv);
return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, "");
} else {
// if we have no information, we have to assume the key is right
return true;
}
} else {
throw new Error("Unknown algorithm");
}
public checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
return this.storageImpl.checkKey(key, info);
}
/**
* Store an encrypted secret on the server
*
* @param name - The name of the secret
* @param secret - The secret contents.
* @param keys - The IDs of the keys to use to encrypt the secret
* or null/undefined to use the default key.
*/
public async store(name: string, secret: string, keys?: string[] | null): Promise<void> {
const encrypted: Record<string, IEncryptedPayload> = {};
if (!keys) {
const defaultKeyId = await this.getDefaultKeyId();
if (!defaultKeyId) {
throw new Error("No keys specified and no default key present");
}
keys = [defaultKeyId];
}
if (keys.length === 0) {
throw new Error("Zero keys given to encrypt with!");
}
for (const keyId of keys) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
"m.secret_storage.key." + keyId,
);
if (!keyInfo) {
throw new Error("Unknown key: " + keyId);
}
// encrypt secret, based on the algorithm
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const keys = { [keyId]: keyInfo };
const [, encryption] = await this.getSecretStorageKey(keys, name);
encrypted[keyId] = await encryption.encrypt(secret);
} else {
logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm);
// do nothing if we don't understand the encryption algorithm
}
}
// save encrypted secret
await this.accountDataAdapter.setAccountData(name, { encrypted });
public store(name: string, secret: string, keys?: string[] | null): Promise<void> {
return this.storageImpl.store(name, secret, keys);
}
/**
* Get a secret from storage.
*
* @param name - the name of the secret
*
* @returns the contents of the secret
*/
public async get(name: string): Promise<string | undefined> {
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo) {
return;
}
if (!secretInfo.encrypted) {
throw new Error("Content is not encrypted!");
}
// get possible keys to decrypt
const keys: Record<string, SecretStorageKeyDescription> = {};
for (const keyId of Object.keys(secretInfo.encrypted)) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
"m.secret_storage.key." + keyId,
);
const encInfo = secretInfo.encrypted[keyId];
// only use keys we understand the encryption algorithm of
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
keys[keyId] = keyInfo;
}
}
}
if (Object.keys(keys).length === 0) {
throw new Error(
`Could not decrypt ${name} because none of ` +
`the keys it is encrypted with are for a supported algorithm`,
);
}
// fetch private key from app
const [keyId, decryption] = await this.getSecretStorageKey(keys, name);
const encInfo = secretInfo.encrypted[keyId];
return decryption.decrypt(encInfo);
public get(name: string): Promise<string | undefined> {
return this.storageImpl.get(name);
}
/**
* Check if a secret is stored on the server.
*
* @param name - the name of the secret
*
* @returns map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*/
public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
// check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo?.encrypted) return null;
const ret: Record<string, SecretStorageKeyDescription> = {};
// filter secret encryption keys with supported algorithm
for (const keyId of Object.keys(secretInfo.encrypted)) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
"m.secret_storage.key." + keyId,
);
if (!keyInfo) continue;
const encInfo = secretInfo.encrypted[keyId];
// only use keys we understand the encryption algorithm of
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
ret[keyId] = keyInfo;
}
}
}
return Object.keys(ret).length ? ret : null;
return this.storageImpl.isStored(name);
}
/**
* Request a secret from another device
*
* @param name - the name of the secret to request
* @param devices - the devices to request the secret from
*/
public request(this: SecretStorage<MatrixClient>, name: string, devices: string[]): ISecretRequest {
const requestId = this.baseApis.makeTxnId();
const deferred = defer<string>();
this.requests.set(requestId, { name, devices, deferred });
const cancel = (reason: string): void => {
// send cancellation event
const cancelData = {
action: "request_cancellation",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
};
const toDevice: Map<string, typeof cancelData> = new Map();
for (const device of devices) {
toDevice.set(device, cancelData);
}
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
// and reject the promise so that anyone waiting on it will be
// notified
deferred.reject(new Error(reason || "Cancelled"));
};
// send request to devices
const requestData = {
name,
action: "request",
requesting_device_id: this.baseApis.deviceId,
request_id: requestId,
[ToDeviceMessageId]: uuidv4(),
};
const toDevice: Map<string, typeof requestData> = new Map();
for (const device of devices) {
toDevice.set(device, requestData);
}
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
return {
requestId,
promise: deferred.promise,
cancel,
};
public request(name: string, devices: string[]): ISecretRequest {
return this.sharingImpl.request(name, devices);
}
public async onRequestReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): Promise<void> {
const sender = event.getSender();
const content = event.getContent();
if (
sender !== this.baseApis.getUserId() ||
!(content.name && content.action && content.requesting_device_id && content.request_id)
) {
// ignore requests from anyone else, for now
return;
}
const deviceId = content.requesting_device_id;
// check if it's a cancel
if (content.action === "request_cancellation") {
/*
Looks like we intended to emit events when we got cancelations, but
we never put anything in the _incomingRequests object, and the request
itself doesn't use events anyway so if we were to wire up cancellations,
they probably ought to use the same callback interface. I'm leaving them
disabled for now while converting this file to typescript.
if (this._incomingRequests[deviceId]
&& this._incomingRequests[deviceId][content.request_id]) {
logger.info(
"received request cancellation for secret (" + sender +
", " + deviceId + ", " + content.request_id + ")",
);
this.baseApis.emit("crypto.secrets.requestCancelled", {
user_id: sender,
device_id: deviceId,
request_id: content.request_id,
});
}
*/
} else if (content.action === "request") {
if (deviceId === this.baseApis.deviceId) {
// no point in trying to send ourself the secret
return;
}
// check if we have the secret
logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
if (!this.cryptoCallbacks.onSecretRequested) {
return;
}
const secret = await this.cryptoCallbacks.onSecretRequested(
sender,
deviceId,
content.request_id,
content.name,
this.baseApis.checkDeviceTrust(sender, deviceId),
);
if (secret) {
logger.info(`Preparing ${content.name} secret for ${deviceId}`);
const payload = {
type: "m.secret.send",
content: {
request_id: content.request_id,
secret: secret,
},
};
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
await olmlib.ensureOlmSessionsForDevices(
this.baseApis.crypto!.olmDevice,
this.baseApis,
new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
);
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.baseApis.getUserId()!,
this.baseApis.deviceId!,
this.baseApis.crypto!.olmDevice,
sender,
this.baseApis.getStoredDevice(sender, deviceId)!,
payload,
);
const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
logger.info(`Sending ${content.name} secret for ${deviceId}`);
this.baseApis.sendToDevice("m.room.encrypted", contentMap);
} else {
logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
}
}
public onRequestReceived(event: MatrixEvent): Promise<void> {
return this.sharingImpl.onRequestReceived(event);
}
public onSecretReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): void {
if (event.getSender() !== this.baseApis.getUserId()) {
// we shouldn't be receiving secrets from anyone else, so ignore
// because someone could be trying to send us bogus data
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("secret event not properly encrypted");
return;
}
const content = event.getContent();
const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey() || "",
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
logger.log("got secret share for request", content.request_id);
const requestControl = this.requests.get(content.request_id);
if (requestControl) {
// make sure that the device that sent it is one of the devices that
// we requested from
const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey()!,
);
if (!deviceInfo) {
logger.log("secret share from unknown device with key", event.getSenderKey());
return;
}
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
return;
}
// unsure that the sender is trusted. In theory, this check is
// unnecessary since we only accept secret shares from devices that
// we requested from, but it doesn't hurt.
const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
if (!deviceTrust.isVerified()) {
logger.log("secret share from unverified device");
return;
}
logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
requestControl.deferred.resolve(content.secret);
}
}
private async getSecretStorageKey(
keys: Record<string, SecretStorageKeyDescription>,
name: string,
): Promise<[string, IDecryptors]> {
if (!this.cryptoCallbacks.getSecretStorageKey) {
throw new Error("No getSecretStorageKey callback supplied");
}
const returned = await this.cryptoCallbacks.getSecretStorageKey({ keys }, name);
if (!returned) {
throw new Error("getSecretStorageKey callback returned falsey");
}
if (returned.length < 2) {
throw new Error("getSecretStorageKey callback returned invalid data");
}
const [keyId, privateKey] = returned;
if (!keys[keyId]) {
throw new Error("App returned unknown key from getSecretStorageKey!");
}
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const decryption = {
encrypt: function (secret: string): Promise<IEncryptedPayload> {
return encryptAES(secret, privateKey, name);
},
decrypt: function (encInfo: IEncryptedPayload): Promise<string> {
return decryptAES(encInfo, privateKey, name);
},
};
return [keyId, decryption];
} else {
throw new Error("Unknown key type: " + keys[keyId].algorithm);
}
public onSecretReceived(event: MatrixEvent): void {
this.sharingImpl.onSecretReceived(event);
}
}
+17 -6
View File
@@ -192,12 +192,23 @@ class OlmDecryption extends DecryptionAlgorithm {
});
}
// check that the device that encrypted the event belongs to the user
// that the event claims it's from. We need to make sure that our
// device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out. Some event handlers, such as
// secret sharing, may be more strict and reject events that come from
// unknown devices.
// check that the device that encrypted the event belongs to the user that the event claims it's from.
//
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
// more strict and reject events that come from unknown devices.
//
// This is a defence against the following scenario:
//
// * Alice has verified Bob and Mallory.
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
// senderkey, but claiming to be from Bob.
// * Mallory sends more events using that session, claiming to be from Bob.
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
// events as verified even though the sender is forged.
//
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
+5 -16
View File
@@ -16,22 +16,18 @@ limitations under the License.
import { DeviceInfo } from "./deviceinfo";
import { IKeyBackupInfo } from "./keybackup";
import { PassphraseInfo } from "../secret-storage";
import type { AddSecretStorageKeyOpts } from "../secret-storage";
/* re-exports for backwards compatibility. */
export {
export { CrossSigningKey } from "../crypto-api";
export type {
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
PassphraseInfo as IPassphraseInfo,
SecretStorageKeyDescription as ISecretStorageKeyInfo,
} from "../secret-storage";
// TODO: Merge this with crypto.js once converted
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
export interface IEncryptedEventInfo {
/**
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
@@ -65,7 +61,7 @@ export interface IEncryptedEventInfo {
}
export interface IRecoveryKey {
keyInfo?: IAddSecretStorageKeyOpts;
keyInfo?: AddSecretStorageKeyOpts;
privateKey: Uint8Array;
encodedPrivateKey?: string;
}
@@ -105,13 +101,6 @@ export interface ICreateSecretStorageOpts {
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
export interface IAddSecretStorageKeyOpts {
pubkey?: string;
passphrase?: PassphraseInfo;
name?: string;
key?: Uint8Array;
}
export interface IImportOpts {
stage: string; // TODO: Enum
successes: number;
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Device } from "../models/device";
import { DeviceInfo } from "./deviceinfo";
/**
* Convert a {@link DeviceInfo} to a {@link Device}.
* @param deviceInfo - deviceInfo to convert
* @param userId - id of the user that owns the device.
*/
export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device {
const keys = new Map<string, string>(Object.entries(deviceInfo.keys));
const displayName = deviceInfo.getDisplayName() || undefined;
const signatures = new Map<string, Map<string, string>>();
if (deviceInfo.signatures) {
for (const userId in deviceInfo.signatures) {
signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
}
}
return new Device({
deviceId: deviceInfo.deviceId,
userId: userId,
keys,
algorithms: deviceInfo.algorithms,
verified: deviceInfo.verified,
signatures,
displayName,
});
}
+1 -6
View File
@@ -15,6 +15,7 @@ limitations under the License.
*/
import { ISignatures } from "../@types/signed";
import { DeviceVerification } from "../models/device";
export interface IDevice {
keys: Record<string, string>;
@@ -25,12 +26,6 @@ export interface IDevice {
signatures?: ISignatures;
}
enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}
/**
* Information about a user's device
*/
+183 -97
View File
@@ -34,16 +34,9 @@ import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms";
import * as algorithms from "./algorithms";
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
import { EncryptionSetupBuilder } from "./EncryptionSetup";
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
import {
IAccountDataClient,
ISecretRequest,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorage,
SecretStorageKeyObject,
SecretStorageKeyTuple,
} from "./SecretStorage";
import {
IAddSecretStorageKeyOpts,
CrossSigningKey,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
@@ -58,7 +51,7 @@ import { keyFromPassphrase } from "./key_passphrase";
import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey";
import { VerificationRequest } from "./verification/request/VerificationRequest";
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
@@ -67,7 +60,7 @@ import { BackupManager } from "./backup";
import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { ToDeviceBatch } from "../models/ToDeviceMessage";
import {
ClientEvent,
@@ -83,14 +76,29 @@ import { ISyncStateData } from "../sync";
import { CryptoStore } from "./store/base";
import { IVerificationChannel } from "./verification/request/Channel";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IContent } from "../models/event";
import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { ISignatures } from "../@types/signed";
import { IMessage } from "./algorithms/olm";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { MapWithDefault, recursiveMapToObject } from "../utils";
import { SecretStorageKeyDescription } from "../secret-storage";
import {
AccountDataClient,
AddSecretStorageKeyOpts,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorageCallbacks,
SecretStorageKeyDescription,
SecretStorageKeyObject,
SecretStorageKeyTuple,
ServerSideSecretStorageImpl,
} from "../secret-storage";
import { ISecretRequest } from "./SecretSharing";
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
import { Device, DeviceMap } from "../models/device";
import { deviceInfoToDevice } from "./device-converter";
/* re-exports for backwards compatibility */
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -127,24 +135,10 @@ interface IInitOpts {
pickleKey?: string;
}
export interface IBootstrapCrossSigningOpts {
/** Optional. Reset even if keys already exist. */
setupNewCrossSigning?: boolean;
/**
* A function that makes the request requiring auth. Receives the auth data as an object.
* Can be called multiple times, first with an empty authDict, to obtain the flows.
*/
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
}
export interface ICryptoCallbacks {
export interface ICryptoCallbacks extends SecretStorageCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
getSecretStorageKey?: (
keys: { keys: Record<string, SecretStorageKeyDescription> },
name: string,
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
onSecretRequested?: (
userId: string,
@@ -252,8 +246,8 @@ export enum CryptoEvent {
export type CryptoEventHandlerMap = {
/**
* Fires when a device is marked as verified/unverified/blocked/unblocked by
* {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or
* {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}.
* {@link MatrixClient#setDeviceVerified | MatrixClient.setDeviceVerified} or
* {@link MatrixClient#setDeviceBlocked | MatrixClient.setDeviceBlocked}.
*
* @param userId - the owner of the verified device
* @param deviceId - the id of the verified device
@@ -356,7 +350,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public readonly olmDevice: OlmDevice;
public readonly deviceList: DeviceList;
public readonly dehydrationManager: DehydrationManager;
public readonly secretStorage: SecretStorage;
public readonly secretStorage: LegacySecretStorage;
private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>;
private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>;
@@ -483,15 +477,15 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
// try to get key from secret storage
const storedKey = await this.getSecret("m.megolm_backup.v1");
const storedKey = await this.secretStorage.get("m.megolm_backup.v1");
if (storedKey) {
// ensure that the key is in the right format. If not, fix the key and
// store the fixed version
const fixedKey = fixBackupKey(storedKey);
if (fixedKey) {
const keys = await this.getSecretStorageKey();
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
const keys = await this.secretStorage.getKey();
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
}
return olmlib.decodeBase64(fixedKey || storedKey);
@@ -529,7 +523,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks);
// Yes, we pass the client twice here: see SecretStorage
this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis);
this.secretStorage = new LegacySecretStorage(baseApis as AccountDataClient, cryptoCallbacks, baseApis);
this.dehydrationManager = new DehydrationManager(this);
// Assuming no app-supplied callback, default to getting from SSSS.
@@ -612,18 +606,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns True if trusting cross-signed devices
*/
public getTrustCrossSignedDevices(): boolean {
return this.trustCrossSignedDevices;
}
/**
* @deprecated Use {@link CryptoApi#getTrustCrossSignedDevices}.
*/
public getCryptoTrustCrossSignedDevices(): boolean {
return this.trustCrossSignedDevices;
}
/**
* See getCryptoTrustCrossSignedDevices
* This may be set before initCrypto() is called to ensure no races occur.
*
* @param val - True to trust cross-signed devices
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
public setTrustCrossSignedDevices(val: boolean): void {
this.trustCrossSignedDevices = val;
for (const userId of this.deviceList.getKnownUserIds()) {
@@ -641,6 +640,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
}
/**
* @deprecated Use {@link CryptoApi#setTrustCrossSignedDevices}.
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
this.setTrustCrossSignedDevices(val);
}
/**
* Create a recovery key from a user-supplied passphrase.
*
@@ -761,7 +767,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public async bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning,
}: IBootstrapCrossSigningOpts = {}): Promise<void> {
}: BootstrapCrossSigningOpts = {}): Promise<void> {
logger.log("Bootstrapping cross-signing");
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
@@ -834,10 +840,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// done as part of setting up secret storage later.
const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys;
if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) {
const secretStorage = new SecretStorage(
const secretStorage = new ServerSideSecretStorageImpl(
builder.accountDataClientAdapter,
builder.ssssCryptoCallbacks,
undefined,
);
if (await secretStorage.hasKey()) {
logger.log("Storing new cross-signing private keys in secret storage");
@@ -900,17 +905,16 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
logger.log("Bootstrapping Secure Secret Storage");
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
const secretStorage = new SecretStorage(
const secretStorage = new ServerSideSecretStorageImpl(
builder.accountDataClientAdapter,
builder.ssssCryptoCallbacks,
undefined,
);
// the ID of the new SSSS key, if we create one
let newKeyId: string | null = null;
// create a new SSSS key and set it as default
const createSSSS = async (opts: IAddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
const createSSSS = async (opts: AddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
if (privateKey) {
opts.key = privateKey;
}
@@ -959,7 +963,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
};
const oldSSSSKey = await this.getSecretStorageKey();
const oldSSSSKey = await this.secretStorage.getKey();
const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null];
const storageExists =
!setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES;
@@ -984,7 +988,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// secrets using it, in theory. We could move them to the new key but a)
// that would mean we'd need to prompt for the old passphrase, and b)
// it's not clear that would be the right thing to do anyway.
const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
const { keyInfo = {} as AddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
newKeyId = await createSSSS(keyInfo, privateKey);
} else if (!storageExists && keyBackupInfo) {
// we have an existing backup, but no SSSS
@@ -995,7 +999,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.());
// create a new SSSS key and use the backup key as the new SSSS key
const opts = {} as IAddSecretStorageKeyOpts;
const opts = {} as AddSecretStorageKeyOpts;
if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) {
// FIXME: ???
@@ -1109,30 +1113,48 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
logger.log("Secure Secret Storage ready");
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
*/
public addSecretStorageKey(
algorithm: string,
opts: IAddSecretStorageKeyOpts,
opts: AddSecretStorageKeyOpts,
keyID?: string,
): Promise<SecretStorageKeyObject> {
return this.secretStorage.addKey(algorithm, opts, keyID);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
*/
public hasSecretStorageKey(keyID?: string): Promise<boolean> {
return this.secretStorage.hasKey(keyID);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getKey}.
*/
public getSecretStorageKey(keyID?: string): Promise<SecretStorageKeyTuple | null> {
return this.secretStorage.getKey(keyID);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
*/
public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
return this.secretStorage.store(name, secret, keys);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
*/
public getSecret(name: string): Promise<string | undefined> {
return this.secretStorage.get(name);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
*/
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
return this.secretStorage.isStored(name);
}
@@ -1144,14 +1166,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.secretStorage.request(name, devices);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
*/
public getDefaultSecretStorageKeyId(): Promise<string | null> {
return this.secretStorage.getDefaultKeyId();
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
*/
public setDefaultSecretStorageKeyId(k: string): Promise<void> {
return this.secretStorage.setDefaultKeyId(k);
}
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
*/
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
return this.secretStorage.checkKey(key, info);
}
@@ -1389,6 +1420,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns the key ID
*/
public getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
return Promise.resolve(this.getCrossSigningId(type));
}
// old name, for backwards compatibility
public getCrossSigningId(type: string): string | null {
return this.crossSigningInfo.getId(type);
}
@@ -1422,10 +1458,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Check whether a given device is trusted.
*
* @param userId - The ID of the user whose devices is to be checked.
* @param userId - The ID of the user whose device is to be checked.
* @param deviceId - The ID of the device to check
*
* @returns
*/
public async getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<DeviceVerificationStatus | null> {
const device = this.deviceList.getStoredDevice(userId, deviceId);
if (!device) {
return null;
}
return this.checkDeviceInfoTrust(userId, device);
}
/**
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
const device = this.deviceList.getStoredDevice(userId, deviceId);
@@ -1438,7 +1486,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param userId - The ID of the user whose devices is to be checked.
* @param device - The device info object to check
*
* @returns
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
const trustedLocally = !!device?.isVerified();
@@ -1787,8 +1835,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param value - whether to blacklist all unverified devices by default
*
* @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For
* internal code, set {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
this.globalBlacklistUnverifiedDevices = value;
@@ -1797,8 +1844,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* @returns whether to blacklist all unverified devices by default
*
* @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For
* internal code, reference {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public getGlobalBlacklistUnverifiedDevices(): boolean {
return this.globalBlacklistUnverifiedDevices;
@@ -1823,24 +1869,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
/**
* Stores the current one_time_key count which will be handled later (in a call of
* onSyncCompleted). The count is e.g. coming from a /sync response.
*
* @param currentCount - The current count of one_time_keys to be stored
*/
public updateOneTimeKeyCount(currentCount: number): void {
if (isFinite(currentCount)) {
this.oneTimeKeyCount = currentCount;
} else {
throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
}
}
public setNeedsNewFallback(needsNewFallback: boolean): void {
this.needsNewFallback = needsNewFallback;
}
public getNeedsNewFallback(): boolean {
return !!this.needsNewFallback;
}
@@ -1976,7 +2004,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
fallbackJson["signed_curve25519:" + keyId] = k;
promises.push(this.signObject(k));
}
this.setNeedsNewFallback(false);
this.needsNewFallback = false;
}
const oneTimeKeys = await this.olmDevice.getOneTimeKeys();
@@ -2040,6 +2068,54 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.deviceList.getStoredDevicesForUser(userId);
}
/**
* Get the device information for the given list of users.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
const deviceMapByUserId = new Map<string, Map<string, Device>>();
// Keep the users without device to download theirs keys
const usersWithoutDeviceInfo: string[] = [];
for (const userId of userIds) {
const deviceInfos = await this.getStoredDevicesForUser(userId);
// If there are device infos for a userId, we transform it into a map
// Else, the keys will be downloaded after
if (deviceInfos) {
const deviceMap = new Map(
// Convert DeviceInfo to Device
deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]),
);
deviceMapByUserId.set(userId, deviceMap);
} else {
usersWithoutDeviceInfo.push(userId);
}
}
// Download device info for users without device infos
if (downloadUncached && usersWithoutDeviceInfo.length > 0) {
const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo);
newDeviceInfoMap.forEach((deviceInfoMap, userId) => {
const deviceMap = new Map<string, Device>();
// Convert DeviceInfo to Device
deviceInfoMap.forEach((deviceInfo, deviceId) =>
deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)),
);
// Put the new device infos into the returned map
deviceMapByUserId.set(userId, deviceMap);
});
}
return deviceMapByUserId;
}
/**
* Get the stored keys for a single device
*
@@ -2693,7 +2769,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns resolves once the sessions are complete, to
* an Object mapping from userId to deviceId to
* {@link OlmSessionResult}
* `IOlmSessionResult`
*/
public ensureOlmSessionsForUsers(
users: string[],
@@ -2917,21 +2993,12 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Handle the notification from /sync or /keys/changes that device lists have
* Handle the notification from /sync that device lists have
* been changed.
*
* @param syncData - Object containing sync tokens associated with this sync
* @param syncDeviceLists - device_lists field from /sync, or response from
* /keys/changes
* @param deviceLists - device_lists field from /sync
*/
public async handleDeviceListChanges(
syncData: ISyncStateData,
syncDeviceLists: Required<ISyncResponse>["device_lists"],
): Promise<void> {
// Initial syncs don't have device change lists. We'll either get the complete list
// of changes for the interval or will have invalidated everything in willProcessSync
if (!syncData.oldSyncToken) return;
public async processDeviceLists(deviceLists: IDeviceLists): Promise<void> {
// Here, we're relying on the fact that we only ever save the sync data after
// sucessfully saving the device list data, so we're guaranteed that the device
// list store is at least as fresh as the sync token from the sync store, ie.
@@ -2940,7 +3007,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// If we didn't make this assumption, we'd have to use the /keys/changes API
// to get key changes between the sync token in the device list and the 'old'
// sync token used here to make sure we didn't miss any.
await this.evalDeviceListChanges(syncDeviceLists);
await this.evalDeviceListChanges(deviceLists);
}
/**
@@ -3221,14 +3288,33 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
public preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0;
this.updateOneTimeKeyCount(currentCount);
return Promise.resolve();
/**
* Stores the current one_time_key count which will be handled later (in a call of
* onSyncCompleted).
*
* @param currentCount - The current count of one_time_keys to be stored
*/
private updateOneTimeKeyCount(currentCount: number): void {
if (isFinite(currentCount)) {
this.oneTimeKeyCount = currentCount;
} else {
throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
}
}
public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519"));
public processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void> {
if (oneTimeKeysCounts !== undefined) {
this.updateOneTimeKeyCount(oneTimeKeysCounts["signed_curve25519"] || 0);
}
if (unusedFallbackKeys !== undefined) {
// If `unusedFallbackKeys` is defined, that means `device_unused_fallback_key_types`
// is present in the sync response, which indicates that the server supports fallback keys.
//
// If there's no unused signed_curve25519 fallback key, we need a new one.
this.needsNewFallback = !unusedFallbackKeys.includes("signed_curve25519");
}
return Promise.resolve();
}
@@ -3784,7 +3870,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param algorithm - crypto algorithm
*
* @throws {@link DecryptionError} if the algorithm is unknown
* @throws `DecryptionError` if the algorithm is unknown
*/
public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm {
let decryptors: Map<string, DecryptionAlgorithm> | undefined;
+1 -1
View File
@@ -211,7 +211,7 @@ export interface OutgoingRoomKeyRequest {
*/
requestBody: IRoomKeyRequestBody;
/**
* current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager})
* current state of this request
*/
state: RoomKeyRequestState;
}
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { logger, PrefixedLogger } from "../../logger";
import * as utils from "../../utils";
import { deepCompare } from "../../utils";
import {
CryptoStore,
IDeviceData,
@@ -158,7 +158,7 @@ export class Backend implements CryptoStore {
const existing = cursor.value;
if (utils.deepCompare(existing.requestBody, requestBody)) {
if (deepCompare(existing.requestBody, requestBody)) {
// got a match
callback(existing);
return;
+4 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { logger } from "../../logger";
import * as utils from "../../utils";
import { deepCompare, promiseTry } from "../../utils";
import {
CryptoStore,
IDeviceData,
@@ -90,7 +90,7 @@ export class MemoryCryptoStore implements CryptoStore {
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return utils.promiseTry(() => {
return promiseTry(() => {
// first see if we already have an entry for this request.
const existing = this._getOutgoingRoomKeyRequest(requestBody);
@@ -138,7 +138,7 @@ export class MemoryCryptoStore implements CryptoStore {
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
for (const existing of this.outgoingRoomKeyRequests) {
if (utils.deepCompare(existing.requestBody, requestBody)) {
if (deepCompare(existing.requestBody, requestBody)) {
return existing;
}
}
@@ -338,7 +338,7 @@ export class MemoryCryptoStore implements CryptoStore {
deviceSessions = {};
this.sessions[deviceKey] = deviceSessions;
}
deviceSessions[sessionId] = sessionInfo;
safeSet(deviceSessions, sessionId, sessionInfo);
}
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+7
View File
@@ -25,9 +25,13 @@ export enum ServerSupport {
export enum Feature {
Thread = "Thread",
ThreadUnreadNotifications = "ThreadUnreadNotifications",
/**
* @deprecated this is now exposed as a capability not a feature
*/
LoginTokenRequest = "LoginTokenRequest",
RelationBasedRedactions = "RelationBasedRedactions",
AccountDataDeletion = "AccountDataDeletion",
RelationsRecursion = "RelationsRecursion",
}
type FeatureSupportCondition = {
@@ -53,6 +57,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.AccountDataDeletion]: {
unstablePrefixes: ["org.matrix.msc3391"],
},
[Feature.RelationsRecursion]: {
unstablePrefixes: ["org.matrix.msc3981"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+4 -4
View File
@@ -18,7 +18,7 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import * as utils from "../utils";
import { checkObjectHasKeys, encodeParams } from "../utils";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { Method } from "./method";
import { ConnectionError, MatrixError } from "./errors";
@@ -45,7 +45,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
public readonly opts: O,
) {
utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
opts.onlyData = !!opts.onlyData;
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
}
@@ -66,7 +66,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
* Sets the base URL for the identity server
* @param url - The new base url
*/
public setIdBaseUrl(url: string): void {
public setIdBaseUrl(url?: string): void {
this.opts.idBaseUrl = url;
}
@@ -304,7 +304,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
if (queryParams) {
utils.encodeParams(queryParams, url.searchParams);
encodeParams(queryParams, url.searchParams);
}
return url;
}
+13 -13
View File
@@ -17,7 +17,7 @@ limitations under the License.
import { FetchHttpApi } from "./fetch";
import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
import { MediaPrefix } from "./prefix";
import * as utils from "../utils";
import { defer, QueryDict, removeElement } from "../utils";
import * as callbacks from "../realtime-callbacks";
import { Method } from "./method";
import { ConnectionError } from "./errors";
@@ -58,14 +58,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
total: 0,
abortController,
} as Upload;
const defer = utils.defer<UploadResponse>();
const deferred = defer<UploadResponse>();
if (global.XMLHttpRequest) {
const xhr = new global.XMLHttpRequest();
const timeoutFn = function (): void {
xhr.abort();
defer.reject(new Error("Timeout"));
deferred.reject(new Error("Timeout"));
};
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
@@ -84,16 +84,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
}
if (xhr.status >= 400) {
defer.reject(parseErrorResponse(xhr, xhr.responseText));
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
} else {
defer.resolve(JSON.parse(xhr.responseText));
deferred.resolve(JSON.parse(xhr.responseText));
}
} catch (err) {
if ((<Error>err).name === "AbortError") {
defer.reject(err);
deferred.reject(err);
return;
}
defer.reject(new ConnectionError("request failed", <Error>err));
deferred.reject(new ConnectionError("request failed", <Error>err));
}
break;
}
@@ -131,7 +131,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
xhr.abort();
});
} else {
const queryParams: utils.QueryDict = {};
const queryParams: QueryDict = {};
if (includeFilename && fileName) {
queryParams.filename = fileName;
}
@@ -146,16 +146,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(defer.resolve, defer.reject);
.then(deferred.resolve, deferred.reject);
}
// remove the upload from the list on completion
upload.promise = defer.promise.finally(() => {
utils.removeElement(this.uploads, (elem) => elem === upload);
upload.promise = deferred.promise.finally(() => {
removeElement(this.uploads, (elem) => elem === upload);
});
abortController.signal.addEventListener("abort", () => {
utils.removeElement(this.uploads, (elem) => elem === upload);
defer.reject(new DOMException("Aborted", "AbortError"));
removeElement(this.uploads, (elem) => elem === upload);
deferred.reject(new DOMException("Aborted", "AbortError"));
});
this.uploads.push(upload);
return upload.promise;
+26 -3
View File
@@ -20,6 +20,7 @@ import { logger } from "./logger";
import { MatrixClient } from "./client";
import { defer, IDeferred } from "./utils";
import { MatrixError } from "./http-api";
import { UIAResponse } from "./@types/uia";
const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
@@ -44,7 +45,14 @@ export interface IStageStatus {
error?: string;
}
/**
* Data returned in the body of a 401 response from a UIA endpoint.
*
* @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
*/
export interface IAuthData {
// XXX: many of the fields here (`type`, `available_flows`, `required_stages`, etc) look like they
// shouldn't be here. They aren't in the spec and it's unclear what they are supposed to do. Be wary of using them.
session?: string;
type?: string;
completed?: string[];
@@ -77,6 +85,11 @@ export enum AuthType {
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
}
/**
* The parameters which are submitted as the `auth` dict in a UIA request
*
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
export interface IAuthDict {
// [key: string]: any;
type?: string;
@@ -97,7 +110,7 @@ export interface IAuthDict {
token?: string;
}
class NoAuthFlowFoundError extends Error {
export class NoAuthFlowFoundError extends Error {
public name = "NoAuthFlowFoundError";
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
@@ -106,6 +119,16 @@ class NoAuthFlowFoundError extends Error {
}
}
/**
* The type of an application callback to perform the user-interactive bit of UIA.
*
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
* makes the HTTP request.
*
* 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) => Promise<UIAResponse<T>>) => Promise<T>;
interface IOpts {
/**
* A matrix client to use for the auth process
@@ -139,7 +162,7 @@ interface IOpts {
* The busyChanged callback should be used instead of the background flag.
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
*/
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
doRequest(auth: IAuthDict | null, background: boolean): Promise<IAuthData>;
/**
* Called when the status of the UI auth changes,
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
@@ -441,7 +464,7 @@ export class InteractiveAuth {
* This can be set to true for requests that just poll to see if auth has
* been completed elsewhere.
*/
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
try {
const result = await this.requestCallback(auth, background);
this.attemptAuthDeferred!.resolve(result);
+5 -1
View File
@@ -38,6 +38,7 @@ export * from "./models/poll";
export * from "./models/room-member";
export * from "./models/room-state";
export * from "./models/user";
export * from "./models/device";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
@@ -47,6 +48,7 @@ export * from "./store/memory";
export * from "./store/indexeddb";
export * from "./crypto/store/memory-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store";
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
export * from "./content-repo";
export * from "./@types/event";
export * from "./@types/PushRules";
@@ -62,13 +64,15 @@ export type { MatrixCall } from "./webrtc/call";
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
export type { CryptoApi } from "./crypto-api";
export { DeviceVerificationStatus } from "./crypto-api";
export { CryptoEvent } from "./crypto";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
/**
* Configure a different factory to be used for creating crypto stores
*
* @param fac - a function which will return a new {@link CryptoStore}
* @param fac - a function which will return a new `CryptoStore`
*/
export function setCryptoStoreFactory(fac: () => CryptoStore): void {
cryptoStoreFactory = fac;
+81
View File
@@ -0,0 +1,81 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** State of the verification of the device. */
export enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}
/** A map from user ID to device ID to Device */
export type DeviceMap = Map<string, Map<string, Device>>;
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
/**
* Information on a user's device, as returned by {@link CryptoApi.getUserDeviceInfo}.
*/
export class Device {
/** id of the device */
public readonly deviceId: string;
/** id of the user that owns the device */
public readonly userId: string;
/** list of algorithms supported by this device */
public readonly algorithms: string[];
/** a map from `<key type>:<id> -> <base64-encoded key>` */
public readonly keys: Map<string, string>;
/** whether the device has been verified/blocked by the user */
public readonly verified: DeviceVerification;
/** a map `<userId, map<algorithm:device_id, signature>>` */
public readonly signatures: Map<string, Map<string, string>>;
/** display name of the device */
public readonly displayName?: string;
public constructor(opts: DeviceParameters) {
this.deviceId = opts.deviceId;
this.userId = opts.userId;
this.algorithms = opts.algorithms;
this.keys = opts.keys;
this.verified = opts.verified || DeviceVerification.Unverified;
this.signatures = opts.signatures || new Map();
this.displayName = opts.displayName;
}
/**
* Get the fingerprint for this device (ie, the Ed25519 key)
*
* @returns base64-encoded fingerprint of this device
*/
public getFingerprint(): string | undefined {
return this.keys.get(`ed25519:${this.deviceId}`);
}
/**
* Get the identity key for this device (ie, the Curve25519 key)
*
* @returns base64-encoded identity key of this device
*/
public getIdentityKey(): string | undefined {
return this.keys.get(`curve25519:${this.deviceId}`);
}
}
+37 -4
View File
@@ -37,6 +37,7 @@ import { EventStatus } from "./event-status";
import { DecryptionError } from "../crypto/algorithms";
import { CryptoBackend } from "../common-crypto/CryptoBackend";
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
import { IAnnotatedPushRule } from "../@types/PushRules";
export { EventStatus } from "./event-status";
@@ -121,6 +122,11 @@ export interface IMentions {
room?: boolean;
}
export interface PushDetails {
rule?: IAnnotatedPushRule;
actions?: IActionsObject;
}
/**
* When an event is a visibility change event, as per MSC3531,
* the visibility change implied by the event.
@@ -220,7 +226,8 @@ export type MatrixEventHandlerMap = {
} & Pick<ThreadEventHandlerMap, ThreadEvent.Update>;
export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap> {
private pushActions: IActionsObject | null = null;
// applied push rule and action for this event
private pushDetails: PushDetails = {};
private _replacingEvent: MatrixEvent | null = null;
private _localRedactionEvent: MatrixEvent | null = null;
private _isCancelled = false;
@@ -888,7 +895,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// highlighting when the user's name is mentioned rely on this happening. We also want
// to set the push actions before emitting so that any notification listeners don't
// pick up the wrong contents.
this.setPushActions(null);
this.setPushDetails();
if (options.emit !== false) {
this.emit(MatrixEventEvent.Decrypted, this, err);
@@ -1241,16 +1248,42 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns push actions
*/
public getPushActions(): IActionsObject | null {
return this.pushActions;
return this.pushDetails.actions || null;
}
/**
* Get the push details, if known, for this event
*
* @returns push actions
*/
public getPushDetails(): PushDetails {
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.pushActions = pushActions;
this.pushDetails = {
actions: pushActions || undefined,
};
}
/**
* Set the push details for this event.
*
* @param pushActions - push actions
* @param rule - the executed push rule
*/
public setPushDetails(pushActions?: IActionsObject, rule?: IAnnotatedPushRule): void {
this.pushDetails = {
actions: pushActions,
rule,
};
}
/**
+13
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START } from "matrix-events-sdk";
import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
import { MatrixClient } from "../client";
import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
@@ -266,3 +268,14 @@ export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, P
);
}
}
/**
* Tests whether the event is a start, response or end poll event.
*
* @param event - Event to test
* @returns true if the event is a poll event, else false
*/
export const isPollEvent = (event: MatrixEvent): boolean => {
const eventType = event.getType();
return M_POLL_START.matches(eventType) || M_POLL_RESPONSE.matches(eventType) || M_POLL_END.matches(eventType);
};
+2 -2
View File
@@ -20,7 +20,7 @@ import {
WrappedReceipt,
} from "../@types/read_receipts";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
import { isSupportedReceiptType } from "../utils";
import { MatrixEvent } from "./event";
import { EventType } from "../@types/event";
import { EventTimelineSet } from "./event-timeline-set";
@@ -267,7 +267,7 @@ export abstract class ReadReceipt<
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event)
.filter(function (receipt) {
return utils.isSupportedReceiptType(receipt.type);
return isSupportedReceiptType(receipt.type);
})
.map(function (receipt) {
return receipt.userId;
+7 -7
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { removeDirectionOverrideChars, removeHiddenChars } from "../utils";
import { User } from "./user";
import { MatrixEvent } from "./event";
import { RoomState } from "./room-state";
@@ -206,8 +206,8 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
// not quite raw: we strip direction override chars so it can safely be inserted into
// blocks of text without breaking the text direction
this.rawDisplayName = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
if (!this.rawDisplayName || !utils.removeHiddenChars(this.rawDisplayName)) {
this.rawDisplayName = removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
if (!this.rawDisplayName || !removeHiddenChars(this.rawDisplayName)) {
this.rawDisplayName = this.userId;
}
@@ -407,7 +407,7 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return false;
if (!removeHiddenChars(displayName)) return false;
if (!roomState) return false;
@@ -432,11 +432,11 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
function calculateDisplayName(selfUserId: string, displayName: string | undefined, disambiguate: boolean): string {
if (!displayName || displayName === selfUserId) return selfUserId;
if (disambiguate) return utils.removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
if (disambiguate) return removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return selfUserId;
if (!removeHiddenChars(displayName)) return selfUserId;
// We always strip the direction override characters (LRO and RLO).
// These override the text direction for all subsequent characters
@@ -449,5 +449,5 @@ function calculateDisplayName(selfUserId: string, displayName: string | undefine
// names should flip into the correct direction automatically based on
// the characters, and you can still embed rtl in ltr or vice versa
// with the embed chars or marker chars.
return utils.removeDirectionOverrideChars(displayName);
return removeDirectionOverrideChars(displayName);
}
+6 -6
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { RoomMember } from "./room-member";
import { logger } from "../logger";
import * as utils from "../utils";
import { isNumber, removeHiddenChars } from "../utils";
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client";
@@ -759,7 +759,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @returns An array of user IDs or an empty array.
*/
public getUserIdsWithDisplayName(displayName: string): string[] {
return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
return this.displayNameToUserIds.get(removeHiddenChars(displayName)) ?? [];
}
/**
@@ -798,7 +798,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
let requiredLevel = 50;
if (utils.isNumber(powerLevels[action])) {
if (isNumber(powerLevels[action])) {
requiredLevel = powerLevels[action]!;
}
@@ -928,7 +928,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
powerLevelsEvent &&
powerLevelsEvent.getContent() &&
powerLevelsEvent.getContent().notifications &&
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
) {
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
}
@@ -1058,7 +1058,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
// We clobber the user_id > name lookup but the name -> [user_id] lookup
// means we need to remove that user ID from that array rather than nuking
// the lot.
const strippedOldName = utils.removeHiddenChars(oldName);
const strippedOldName = removeHiddenChars(oldName);
const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
if (existingUserIds) {
@@ -1070,7 +1070,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.userIdsToDisplayNames[userId] = displayName;
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
const strippedDisplayname = displayName && removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
+127 -34
View File
@@ -24,7 +24,7 @@ import {
} from "./event-timeline-set";
import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { compare, removeElement } from "../utils";
import { normalize, noUnsafeEventProps } from "../utils";
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
import { EventStatus } from "./event-status";
@@ -64,7 +64,7 @@ import {
import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { Poll, PollEvent } from "./poll";
import { isPollEvent, Poll, PollEvent } from "./poll";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -324,7 +324,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private unthreadedReceipts = new Map<string, Receipt>();
private readonly timelineSets: EventTimelineSet[];
public readonly polls: Map<string, Poll> = new Map<string, Poll>();
public readonly threadsTimelineSets: EventTimelineSet[] = [];
/**
* Empty array if the timeline sets have not been initialised. After initialisation:
* 0: All threads
* 1: Threads the current user has participated in
*/
public readonly threadsTimelineSets: [] | [EventTimelineSet, EventTimelineSet] = [];
// any filtered timeline sets we're maintaining for this room
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
private timelineNeedsRefresh = false;
@@ -361,22 +368,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* The room summary.
*/
public summary: RoomSummary | null = null;
// legacy fields
/**
* The live event timeline for this room, with the oldest event at index 0.
* Present for backwards compatibility - prefer getLiveTimeline().getEvents()
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getEvents() instead
*/
public timeline!: MatrixEvent[];
/**
* oldState The state of the room at the time of the oldest
* event in the live timeline. Present for backwards compatibility -
* prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
* oldState The state of the room at the time of the oldest event in the live timeline.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead
*/
public oldState!: RoomState;
/**
* currentState The state of the room at the time of the
* newest event in the timeline. Present for backwards compatibility -
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
* currentState The state of the room at the time of the newest event in the timeline.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
*/
public currentState!: RoomState;
public readonly relations = new RelationsContainer(this.client, this);
@@ -386,6 +396,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* This is not a comprehensive list of the threads that exist in this room
*/
private threads = new Map<string, Thread>();
/**
* @deprecated This value is unreliable. It may not contain the last thread.
* Use {@link Room.getLastThread} instead.
*/
public lastThread?: Thread;
/**
@@ -490,7 +505,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.createThreadTimelineSet(ThreadFilterType.My),
]);
const timelineSets = await this.threadTimelineSetsPromise;
this.threadsTimelineSets.push(...timelineSets);
this.threadsTimelineSets[0] = timelineSets[0];
this.threadsTimelineSets[1] = timelineSets[1];
return timelineSets;
} catch (e) {
this.threadTimelineSetsPromise = null;
@@ -717,7 +733,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
);
}
const removed = utils.removeElement(
const removed = removeElement(
this.pendingEventList,
function (ev) {
return ev.getId() == eventId;
@@ -774,6 +790,54 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
/**
* Returns the last live event of this room.
* "last" means latest timestamp.
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
* Unfortunately, this information is currently not available in the client.
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
* "live of this room" means from all live timelines: the room and the threads.
*
* @returns MatrixEvent if there is a last event; else undefined.
*/
public getLastLiveEvent(): MatrixEvent | undefined {
const roomEvents = this.getLiveTimeline().getEvents();
const lastRoomEvent = roomEvents[roomEvents.length - 1] as MatrixEvent | undefined;
const lastThread = this.getLastThread();
if (!lastThread) return lastRoomEvent;
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
}
/**
* Returns the last thread of this room.
* "last" means latest timestamp of the last thread event.
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
* Unfortunately, this information is currently not available in the client.
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
*
* @returns the thread with the most recent event in its live time line. undefined if there is no thread.
*/
public getLastThread(): Thread | undefined {
return this.getThreads().reduce<Thread | undefined>((lastThread: Thread | undefined, thread: Thread) => {
if (!lastThread) return thread;
const threadEvent = thread.events[thread.events.length - 1];
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) {
// Last message of current thread is newer → new last thread.
// Equal also means newer, because it was added to the thread map later.
return thread;
}
return lastThread;
}, undefined);
}
/**
* @returns the membership type (join | leave | invite) for the logged in user
*/
@@ -1897,35 +1961,62 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.threadsReady = true;
}
/**
* Calls {@link processPollEvent} for a list of events.
*
* @param events - List of events
*/
public async processPollEvents(events: MatrixEvent[]): Promise<void> {
const processPollStartEvent = (event: MatrixEvent): void => {
if (!M_POLL_START.matches(event.getType())) return;
for (const event of events) {
try {
// Continue if the event is a clear text, non-poll event.
if (!event.isEncrypted() && !isPollEvent(event)) continue;
/**
* Try to decrypt the event. Promise resolution does not guarantee a successful decryption.
* Retry is handled in {@link processPollEvent}.
*/
await this.client.decryptEventIfNeeded(event);
this.processPollEvent(event);
} catch (err) {
logger.warn("Error processing poll event", event.getId(), err);
}
}
}
/**
* Processes poll events:
* If the event has a decryption failure, it will listen for decryption and tries again.
* If it is a poll start event (`m.poll.start`),
* it creates and stores a Poll model and emits a PollEvent.New event.
* If the event is related to a poll, it will add it to the poll.
* Noop for other cases.
*
* @param event - Event that could be a poll event
*/
private async processPollEvent(event: MatrixEvent): Promise<void> {
if (event.isDecryptionFailure()) {
event.once(MatrixEventEvent.Decrypted, (maybeDecryptedEvent: MatrixEvent) => {
this.processPollEvent(maybeDecryptedEvent);
});
return;
}
if (M_POLL_START.matches(event.getType())) {
try {
const poll = new Poll(event, this.client, this);
this.polls.set(event.getId()!, poll);
this.emit(PollEvent.New, poll);
} catch {}
// poll creation can fail for malformed poll start events
};
return;
}
const processPollRelationEvent = (event: MatrixEvent): void => {
const relationEventId = event.relationEventId;
if (relationEventId && this.polls.has(relationEventId)) {
const poll = this.polls.get(relationEventId);
poll?.onNewRelation(event);
}
};
const relationEventId = event.relationEventId;
const processPollEvent = (event: MatrixEvent): void => {
processPollStartEvent(event);
processPollRelationEvent(event);
};
for (const event of events) {
try {
await this.client.decryptEventIfNeeded(event);
processPollEvent(event);
} catch {}
if (relationEventId && this.polls.has(relationEventId)) {
const poll = this.polls.get(relationEventId);
poll?.onNewRelation(event);
}
}
@@ -1934,6 +2025,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @internal
*/
private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
if (this.threadsTimelineSets.length === 0) return;
const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
const { chunk: events, end } = await this.client.createThreadListMessagesRequest(
@@ -3174,7 +3267,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return true;
});
// make sure members have stable order
otherMembers.sort((a, b) => utils.compare(a.userId, b.userId));
otherMembers.sort((a, b) => compare(a.userId, b.userId));
// only 5 first members, immitate summaryHeroes
otherMembers = otherMembers.slice(0, 5);
otherNames = otherMembers.map((m) => m.name);
+23 -19
View File
@@ -28,6 +28,7 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
import { Feature, ServerSupport } from "../feature";
export enum ThreadEvent {
New = "Thread.new",
@@ -458,25 +459,28 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return Promise.all(
events
.filter((e) => e.isEncrypted())
.map((event: MatrixEvent) => {
if (event.isRelation()) return; // skip - relations don't get edits
return this.client
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
limit: 1,
})
.then((relations) => {
if (relations.events.length) {
event.makeReplaced(relations.events[0]);
}
})
.catch((e) => {
logger.error("Failed to load edits for encrypted thread event", e);
});
}),
);
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport !== ServerSupport.Unsupported) {
return Promise.all(
events
.filter((e) => e.isEncrypted())
.map((event: MatrixEvent) => {
if (event.isRelation()) return; // skip - relations don't get edits
return this.client
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
limit: 1,
})
.then((relations) => {
if (relations.events.length) {
event.makeReplaced(relations.events[0]);
}
})
.catch((e) => {
logger.error("Failed to load edits for encrypted thread event", e);
});
}),
);
}
}
public setEventMetadata(event: Optional<MatrixEvent>): void {
-4
View File
@@ -59,10 +59,6 @@ export class TypedEventEmitter<
return super.emit(event, ...args);
}
public eventNames(): (Events | EventEmitterEvents)[] {
return super.eventNames() as Array<Events | EventEmitterEvents>;
}
public listenerCount(event: Events | EventEmitterEvents): number {
return super.listenerCount(event);
}
+20 -5
View File
@@ -589,7 +589,7 @@ export class PushProcessor {
* @internal
*/
public static partsForDottedKey(str: string): string[] {
const result = [];
const result: string[] = [];
// The current field and whether the previous character was the escape
// character (a backslash).
@@ -688,17 +688,24 @@ export class PushProcessor {
if (!rulesets) {
return null;
}
if (ev.getSender() === this.client.credentials.userId) {
if (ev.getSender() === this.client.getSafeUserId()) {
return null;
}
return this.matchingRuleFromKindSet(ev, rulesets.global);
}
private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets?: IPushRules): IActionsObject {
private pushActionsForEventAndRulesets(
ev: MatrixEvent,
rulesets?: IPushRules,
): {
actions?: IActionsObject;
rule?: IAnnotatedPushRule;
} {
const rule = this.matchingRuleForEventWithRulesets(ev, rulesets);
if (!rule) {
return {} as IActionsObject;
return {};
}
const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
@@ -710,7 +717,7 @@ export class PushProcessor {
actionObj.tweaks.highlight = rule.kind == PushRuleKind.ContentSpecific;
}
return actionObj;
return { actions: actionObj, rule };
}
public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean {
@@ -732,6 +739,14 @@ export class PushProcessor {
* Get the user's push actions for the given event
*/
public actionsForEvent(ev: MatrixEvent): IActionsObject {
const { actions } = this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
return actions || ({} as IActionsObject);
}
public actionsAndRuleForEvent(ev: MatrixEvent): {
actions?: IActionsObject;
rule?: IAnnotatedPushRule;
} {
return this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
}
+169
View File
@@ -0,0 +1,169 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IMinimalEvent } from "./sync-accumulator";
import { EventType } from "./@types/event";
import { isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils";
import { IContent } from "./models/event";
import { ReceiptContent, ReceiptType } from "./@types/read_receipts";
interface AccumulatedReceipt {
data: IMinimalEvent;
type: ReceiptType;
eventId: string;
}
/**
* Summarises the read receipts within a room. Used by the sync accumulator.
*
* Given receipts for users, picks the most recently-received one and provides
* the results in a new fake receipt event returned from
* buildAccumulatedReceiptEvent().
*
* Handles unthreaded receipts and receipts in each thread separately, so the
* returned event contains the most recently received unthreaded receipt, and
* the most recently received receipt in each thread.
*/
export class ReceiptAccumulator {
/** user_id -\> most-recently-received unthreaded receipt */
private unthreadedReadReceipts: Map<string, AccumulatedReceipt> = new Map();
/** thread_id -\> user_id -\> most-recently-received receipt for this thread */
private threadedReadReceipts: MapWithDefault<string, Map<string, AccumulatedReceipt>> = new MapWithDefault(
() => new Map(),
);
/**
* Provide an unthreaded receipt for this user. Overwrites any other
* unthreaded receipt we have for this user.
*/
private setUnthreaded(userId: string, receipt: AccumulatedReceipt): void {
this.unthreadedReadReceipts.set(userId, receipt);
}
/**
* Provide a receipt for this user in this thread. Overwrites any other
* receipt we have for this user in this thread.
*/
private setThreaded(threadId: string, userId: string, receipt: AccumulatedReceipt): void {
this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt);
}
/**
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
* most recently-received unthreaded receipts for each user.
*/
private allUnthreaded(): IterableIterator<[string, AccumulatedReceipt]> {
return this.unthreadedReadReceipts.entries();
}
/**
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
* most recently-received threaded receipts for each user, in all
* threads.
*/
private *allThreaded(): IterableIterator<[string, AccumulatedReceipt]> {
for (const receiptsForThread of this.threadedReadReceipts.values()) {
for (const e of receiptsForThread.entries()) {
yield e;
}
}
}
/**
* Given a list of ephemeral events, find the receipts and store the
* relevant ones to be returned later from buildAccumulatedReceiptEvent().
*/
public consumeEphemeralEvents(events: IMinimalEvent[] | undefined): void {
events?.forEach((e) => {
if (e.type !== EventType.Receipt || !e.content) {
// This means we'll drop unknown ephemeral events but that
// seems okay.
return;
}
// Handle m.receipt events. They clobber based on:
// (user_id, receipt_type)
// but they are keyed in the event as:
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
// so store them in the former so we can accumulate receipt deltas
// quickly and efficiently (we expect a lot of them). Fold the
// receipt type into the key name since we only have 1 at the
// moment (m.read) and nested JSON objects are slower and more
// of a hassle to work with. We'll inflate this back out when
// getJSON() is called.
Object.keys(e.content).forEach((eventId) => {
Object.entries<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => {
if (!isSupportedReceiptType(key)) return;
for (const userId of Object.keys(value)) {
const data = e.content[eventId][key][userId];
const receipt = {
data: e.content[eventId][key][userId],
type: key as ReceiptType,
eventId,
};
if (!data.thread_id) {
this.setUnthreaded(userId, receipt);
} else {
this.setThreaded(data.thread_id, userId, receipt);
}
}
});
});
});
}
/**
* Build a receipt event that contains all relevant information for this
* room, taking the most recently received receipt for each user in an
* unthreaded context, and in each thread.
*/
public buildAccumulatedReceiptEvent(roomId: string): IMinimalEvent | null {
const receiptEvent: IMinimalEvent = {
type: EventType.Receipt,
room_id: roomId,
content: {
// $event_id: { "m.read": { $user_id: $json } }
} as IContent,
};
const receiptEventContent: MapWithDefault<
string,
MapWithDefault<ReceiptType, Map<string, object>>
> = new MapWithDefault(() => new MapWithDefault(() => new Map()));
for (const [userId, receiptData] of this.allUnthreaded()) {
receiptEventContent
.getOrCreate(receiptData.eventId)
.getOrCreate(receiptData.type)
.set(userId, receiptData.data);
}
for (const [userId, receiptData] of this.allThreaded()) {
receiptEventContent
.getOrCreate(receiptData.eventId)
.getOrCreate(receiptData.type)
.set(userId, receiptData.data);
}
receiptEvent.content = recursiveMapToObject(receiptEventContent);
return receiptEventContent.size > 0 ? receiptEvent : null;
}
}
+7 -2
View File
@@ -17,7 +17,7 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import { MatrixClient } from "../client";
import { IMSC3882GetLoginTokenCapability, MatrixClient, UNSTABLE_MSC3882_CAPABILITY } from "../client";
import { CrossSigningInfo } from "../crypto/CrossSigning";
import { DeviceInfo } from "../crypto/deviceinfo";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
@@ -100,9 +100,14 @@ export class MSC3906Rendezvous {
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
// in r1 of MSC3882 the availability is exposed as a capability
const capabilities = await this.client.getCapabilities();
// in r0 of MSC3882 the availability is exposed as a feature flag
const features = await buildFeatureSupportMap(await this.client.getVersions());
const capability = UNSTABLE_MSC3882_CAPABILITY.findIn<IMSC3882GetLoginTokenCapability>(capabilities);
// determine available protocols
if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
if (!capability?.enabled && features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
logger.info("Server doesn't support MSC3882");
await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported });
await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
+37 -1
View File
@@ -23,11 +23,14 @@ import {
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
SigningKeysUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
import { UIAResponse } from "../@types/uia";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
@@ -53,7 +56,7 @@ export class OutgoingRequestProcessor {
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
let resp: string;
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
@@ -79,6 +82,14 @@ export class OutgoingRequestProcessor {
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else if (msg instanceof SigningKeysUploadRequest) {
resp = await this.makeRequestWithUIA(
Method.Post,
"/_matrix/client/v3/keys/device_signing/upload",
{},
msg.body,
uiaCallback,
);
} else {
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
@@ -89,6 +100,31 @@ export class OutgoingRequestProcessor {
}
}
private async makeRequestWithUIA<T>(
method: Method,
path: string,
queryParams: QueryDict,
body: string,
uiaCallback: UIAuthCallback<T> | undefined,
): Promise<string> {
if (!uiaCallback) {
return await this.rawJsonRequest(method, path, queryParams, body);
}
const parsedBody = JSON.parse(body);
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
const newBody = {
...parsedBody,
auth,
};
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
return JSON.parse(resp) as T;
};
const resp = await uiaCallback(makeRequest);
return JSON.stringify(resp);
}
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
const opts = {
// inhibit the JSON stringification and parsing within HttpApi.
+121
View File
@@ -0,0 +1,121 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { Device, DeviceVerification } from "../models/device";
import { DeviceKeys } from "../client";
/**
* Convert a {@link RustSdkCryptoJs.Device} to a {@link Device}
* @param device - Rust Sdk device
* @param userId - owner of the device
*/
export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: RustSdkCryptoJs.UserId): Device {
// Copy rust device keys to Device.keys
const keys = new Map<string, string>();
for (const [keyId, key] of device.keys.entries()) {
keys.set(keyId.toString(), key.toBase64());
}
// Compute verified from device state
let verified: DeviceVerification = DeviceVerification.Unverified;
if (device.isBlacklisted()) {
verified = DeviceVerification.Blocked;
} else if (device.isVerified()) {
verified = DeviceVerification.Verified;
}
// Convert rust signatures to Device.signatures
const signatures = new Map<string, Map<string, string>>();
const mayBeSignatureMap: Map<string, RustSdkCryptoJs.MaybeSignature> | undefined = device.signatures.get(userId);
if (mayBeSignatureMap) {
const convertedSignatures = new Map<string, string>();
// Convert maybeSignatures map to a Map<string, string>
for (const [key, value] of mayBeSignatureMap.entries()) {
if (value.isValid() && value.signature) {
convertedSignatures.set(key, value.signature.toBase64());
}
}
signatures.set(userId.toString(), convertedSignatures);
}
// Convert rust algorithms to algorithms
const rustAlgorithms: RustSdkCryptoJs.EncryptionAlgorithm[] = device.algorithms;
// Use set to ensure that algorithms are not duplicated
const algorithms = new Set<string>();
rustAlgorithms.forEach((algorithm) => {
switch (algorithm) {
case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2:
algorithms.add("m.megolm.v1.aes-sha2");
break;
case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2:
default:
algorithms.add("m.olm.v1.curve25519-aes-sha2");
break;
}
});
return new Device({
deviceId: device.deviceId.toString(),
userId: userId.toString(),
keys,
algorithms: Array.from(algorithms),
verified,
signatures,
displayName: device.displayName,
});
}
/**
* Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>`
* @param deviceKeys - Device keys object to convert
*/
export function deviceKeysToDeviceMap(deviceKeys: DeviceKeys): Map<string, Device> {
return new Map(
Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]),
);
}
// Device from `/keys/query` request
type QueryDevice = DeviceKeys[keyof DeviceKeys];
/**
* Convert `/keys/query` {@link QueryDevice} device to {@link Device}
* @param device - Device from `/keys/query` request
*/
export function downloadDeviceToJsDevice(device: QueryDevice): Device {
const keys = new Map(Object.entries(device.keys));
const displayName = device.unsigned?.device_display_name;
const signatures = new Map<string, Map<string, string>>();
if (device.signatures) {
for (const userId in device.signatures) {
signatures.set(userId, new Map(Object.entries(device.signatures[userId])));
}
}
return new Device({
deviceId: device.device_id,
userId: device.user_id,
keys,
algorithms: device.algorithms,
verified: DeviceVerification.Unverified,
signatures,
displayName,
});
}
+15 -1
View File
@@ -20,11 +20,22 @@ import { RustCrypto } from "./rust-crypto";
import { logger } from "../logger";
import { RUST_SDK_STORE_PREFIX } from "./constants";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { ServerSideSecretStorage } from "../secret-storage";
/**
* Create a new `RustCrypto` implementation
*
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
* We expect it to set the access token, etc.
* @param userId - The local user's User ID.
* @param deviceId - The local user's Device ID.
* @param secretStorage - Interface to server-side secret storage.
*/
export async function initRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
secretStorage: ServerSideSecretStorage,
): Promise<RustCrypto> {
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
await RustSdkCryptoJs.initAsync();
@@ -38,7 +49,10 @@ export async function initRustCrypto(
// TODO: use the pickle key for the passphrase
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
rustCrypto.onRoomKeysUpdated(sessions),
);
logger.info("Completed rust crypto-sdk setup");
return rustCrypto;
+360 -35
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,24 +17,32 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import type { IEncryptedEventInfo } from "../crypto/api";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { RoomMember } from "../models/room-member";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { UserTrustLevel } from "../crypto/CrossSigning";
import { RoomEncryptor } from "./RoomEncryptor";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { KeyClaimManager } from "./KeyClaimManager";
import { MapWithDefault } from "../utils";
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
import { Device, DeviceMap } from "../models/device";
import { ServerSideSecretStorage } from "../secret-storage";
import { CrossSigningKey } from "../crypto/api";
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
*/
export class RustCrypto implements CryptoBackend {
public globalErrorOnUnknownDevices = false;
private _trustCrossSignedDevices = true;
/** whether {@link stop} has been called */
private stopped = false;
@@ -45,17 +53,33 @@ export class RustCrypto implements CryptoBackend {
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
public constructor(
/** The `OlmMachine` from the underlying rust crypto sdk. */
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
/**
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
*
* We expect it to set the access token, etc.
*/
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
/** The local user's User ID. */
_userId: string,
/** The local user's Device ID. */
_deviceId: string,
/** Interface to server-side secret storage */
_secretStorage: ServerSideSecretStorage,
) {
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
this.eventDecryptor = new EventDecryptor(olmMachine);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -101,23 +125,7 @@ export class RustCrypto implements CryptoBackend {
// through decryptEvent and hence get rid of this case.
throw new Error("to-device event was not decrypted in preprocessToDeviceMessages");
}
const res = (await this.olmMachine.decryptRoomEvent(
JSON.stringify({
event_id: event.getId(),
type: event.getWireType(),
sender: event.getSender(),
state_key: event.getStateKey(),
content: event.getWireContent(),
origin_server_ts: event.getTs(),
}),
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
)) as RustSdkCryptoJs.DecryptedRoomEvent;
return {
clearEvent: JSON.parse(res.event),
claimedEd25519Key: res.senderClaimedEd25519Key,
senderCurve25519Key: res.senderCurve25519Key,
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
};
return await this.eventDecryptor.attemptEventDecryption(event);
}
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
@@ -143,9 +151,30 @@ export class RustCrypto implements CryptoBackend {
return new UserTrustLevel(false, false, false);
}
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
public findVerificationRequestDMInProgress(roomId: string): undefined {
// TODO
return new DeviceTrustLevel(false, false, false, false);
return;
}
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
public getStoredCrossSigningForUser(userId: string): null {
// TODO
return null;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -178,6 +207,146 @@ export class RustCrypto implements CryptoBackend {
return [];
}
/**
* Get the device information for the given list of users.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
const deviceMapByUserId = new Map<string, Map<string, Device>>();
const rustTrackedUsers: Set<RustSdkCryptoJs.UserId> = await this.olmMachine.trackedUsers();
// Convert RustSdkCryptoJs.UserId to a `Set<string>`
const trackedUsers = new Set<string>();
rustTrackedUsers.forEach((rustUserId) => trackedUsers.add(rustUserId.toString()));
// Keep untracked user to download their keys after
const untrackedUsers: Set<string> = new Set();
for (const userId of userIds) {
// if this is a tracked user, we can just fetch the device list from the rust-sdk
// (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's
// devices: the rust-sdk will return the last-known device list, which will be good enough.)
if (trackedUsers.has(userId)) {
deviceMapByUserId.set(userId, await this.getUserDevices(userId));
} else {
untrackedUsers.add(userId);
}
}
// for any users whose device lists we are not tracking, fall back to downloading the device list
// over HTTP.
if (downloadUncached && untrackedUsers.size >= 1) {
const queryResult = await this.downloadDeviceList(untrackedUsers);
Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) =>
deviceMapByUserId.set(userId, deviceKeysToDeviceMap(deviceKeys)),
);
}
return deviceMapByUserId;
}
/**
* Get the device list for the given user from the olm machine
* @param userId - Rust SDK UserId
*/
private async getUserDevices(userId: string): Promise<Map<string, Device>> {
const rustUserId = new RustSdkCryptoJs.UserId(userId);
const devices: RustSdkCryptoJs.UserDevices = await this.olmMachine.getUserDevices(rustUserId);
return new Map(
devices
.devices()
.map((device: RustSdkCryptoJs.Device) => [
device.deviceId.toString(),
rustDeviceToJsDevice(device, rustUserId),
]),
);
}
/**
* Download the given user keys by calling `/keys/query` request
* @param untrackedUsers - download keys of these users
*/
private async downloadDeviceList(untrackedUsers: Set<string>): Promise<IDownloadKeyResult> {
const queryBody: IQueryKeysRequest = { device_keys: {} };
untrackedUsers.forEach((user) => (queryBody.device_keys[user] = []));
return await this.http.authedRequest(Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, {
prefix: "",
});
}
/**
* Implementation of {@link CryptoApi#getTrustCrossSignedDevices}.
*/
public getTrustCrossSignedDevices(): boolean {
return this._trustCrossSignedDevices;
}
/**
* Implementation of {@link CryptoApi#setTrustCrossSignedDevices}.
*/
public setTrustCrossSignedDevices(val: boolean): void {
this._trustCrossSignedDevices = val;
// TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged
// events. Maybe we need to do the same?
}
/**
* Implementation of {@link CryptoApi#getDeviceVerificationStatus}.
*/
public async getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<DeviceVerificationStatus | null> {
const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.DeviceId(deviceId),
);
if (!device) return null;
return new DeviceVerificationStatus({
signedByOwner: device.isCrossSignedByOwner(),
crossSigningVerified: device.isCrossSigningTrusted(),
localVerified: device.isLocallyTrusted(),
trustCrossSignedDevices: this._trustCrossSignedDevices,
});
}
/**
* Implementation of {@link CryptoApi#isCrossSigningReady}
*/
public async isCrossSigningReady(): Promise<boolean> {
return false;
}
/**
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
*/
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
// TODO
return null;
}
/**
* Implementation of {@link CryptoApi#boostrapCrossSigning}
*/
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
logger.log("Cross-signing ready");
}
/**
* Implementation of {@link CryptoApi#isSecretStorageReady}
*/
public async isSecretStorageReady(): Promise<boolean> {
return false;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
@@ -189,20 +358,23 @@ export class RustCrypto implements CryptoBackend {
* @param events - the received to-device messages
* @param oneTimeKeysCounts - the received one time key counts
* @param unusedFallbackKeys - the received unused fallback keys
* @param devices - the received device list updates
* @returns A list of preprocessed to-device messages.
*/
private async receiveSyncChanges({
events,
oneTimeKeysCounts = new Map<string, number>(),
unusedFallbackKeys = new Set<string>(),
unusedFallbackKeys,
devices = new RustSdkCryptoJs.DeviceLists(),
}: {
events?: IToDeviceEvent[];
oneTimeKeysCounts?: Map<string, number>;
unusedFallbackKeys?: Set<string>;
devices?: RustSdkCryptoJs.DeviceLists;
}): Promise<IToDeviceEvent[]> {
const result = await this.olmMachine.receiveSyncChanges(
events ? JSON.stringify(events) : "[]",
new RustSdkCryptoJs.DeviceLists(),
devices,
oneTimeKeysCounts,
unusedFallbackKeys,
);
@@ -222,22 +394,37 @@ export class RustCrypto implements CryptoBackend {
return this.receiveSyncChanges({ events });
}
/** called by the sync loop to preprocess one time key counts
/** called by the sync loop to process one time key counts and unused fallback keys
*
* @param oneTimeKeysCounts - the received one time key counts
* @returns A list of preprocessed to-device messages.
* @param unusedFallbackKeys - the received unused fallback keys
*/
public async preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
await this.receiveSyncChanges({ oneTimeKeysCounts });
public async processKeyCounts(
oneTimeKeysCounts?: Record<string, number>,
unusedFallbackKeys?: string[],
): Promise<void> {
const mapOneTimeKeysCount = oneTimeKeysCounts && new Map<string, number>(Object.entries(oneTimeKeysCounts));
const setUnusedFallbackKeys = unusedFallbackKeys && new Set<string>(unusedFallbackKeys);
if (mapOneTimeKeysCount !== undefined || setUnusedFallbackKeys !== undefined) {
await this.receiveSyncChanges({
oneTimeKeysCounts: mapOneTimeKeysCount,
unusedFallbackKeys: setUnusedFallbackKeys,
});
}
}
/** called by the sync loop to preprocess unused fallback keys
/** called by the sync loop to process the notification that device lists have
* been changed.
*
* @param unusedFallbackKeys - the received unused fallback keys
* @returns A list of preprocessed to-device messages.
* @param deviceLists - device_lists field from /sync
*/
public async preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
await this.receiveSyncChanges({ unusedFallbackKeys });
public async processDeviceLists(deviceLists: IDeviceLists): Promise<void> {
const devices = new RustSdkCryptoJs.DeviceLists(
deviceLists.changed?.map((userId) => new RustSdkCryptoJs.UserId(userId)),
deviceLists.left?.map((userId) => new RustSdkCryptoJs.UserId(userId)),
);
await this.receiveSyncChanges({ devices });
}
/** called by the sync loop on m.room.encrypted events
@@ -303,6 +490,43 @@ export class RustCrypto implements CryptoBackend {
enc.onRoomMembership(member);
}
/** Callback for OlmMachine.registerRoomKeyUpdatedCallback
*
* Called by the rust-sdk whenever there is an update to (megolm) room keys. We
* check if we have any events waiting for the given keys, and schedule them for
* a decryption retry if so.
*
* @param keys - details of the updated keys
*/
public async onRoomKeysUpdated(keys: RustSdkCryptoJs.RoomKeyInfo[]): Promise<void> {
for (const key of keys) {
this.onRoomKeyUpdated(key);
}
}
private onRoomKeyUpdated(key: RustSdkCryptoJs.RoomKeyInfo): void {
logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`);
const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key);
if (pendingList.length === 0) return;
logger.debug(
"Retrying decryption on events:",
pendingList.map((e) => `${e.getId()}`),
);
// Have another go at decrypting events with this key.
//
// We don't want to end up blocking the callback from Rust, which could otherwise end up dropping updates,
// so we don't wait for the decryption to complete. In any case, there is no need to wait:
// MatrixEvent.attemptDecryption ensures that there is only one decryption attempt happening at once,
// and deduplicates repeated attempts for the same event.
for (const ev of pendingList) {
ev.attemptDecryption(this, { isRetry: true }).catch((_e) => {
logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
});
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Outgoing requests
@@ -332,3 +556,104 @@ export class RustCrypto implements CryptoBackend {
}
}
}
class EventDecryptor {
/**
* Events which we couldn't decrypt due to unknown sessions / indexes.
*
* Map from senderKey to sessionId to Set of MatrixEvents
*/
private eventsPendingKey = new MapWithDefault<string, MapWithDefault<string, Set<MatrixEvent>>>(
() => new MapWithDefault<string, Set<MatrixEvent>>(() => new Set()),
);
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine) {}
public async attemptEventDecryption(event: MatrixEvent): Promise<IEventDecryptionResult> {
logger.info("Attempting decryption of event", event);
// add the event to the pending list *before* attempting to decrypt.
// then, if the key turns up while decryption is in progress (and
// decryption fails), we will schedule a retry.
// (fixes https://github.com/vector-im/element-web/issues/5001)
this.addEventToPendingList(event);
const res = (await this.olmMachine.decryptRoomEvent(
JSON.stringify({
event_id: event.getId(),
type: event.getWireType(),
sender: event.getSender(),
state_key: event.getStateKey(),
content: event.getWireContent(),
origin_server_ts: event.getTs(),
}),
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
)) as RustSdkCryptoJs.DecryptedRoomEvent;
// Success. We can remove the event from the pending list, if
// that hasn't already happened.
this.removeEventFromPendingList(event);
return {
clearEvent: JSON.parse(res.event),
claimedEd25519Key: res.senderClaimedEd25519Key,
senderCurve25519Key: res.senderCurve25519Key,
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
};
}
/**
* Look for events which are waiting for a given megolm session
*
* Returns a list of events which were encrypted by `session` and could not be decrypted
*
* @param session -
*/
public getEventsPendingRoomKey(session: RustSdkCryptoJs.RoomKeyInfo): MatrixEvent[] {
const senderPendingEvents = this.eventsPendingKey.get(session.senderKey.toBase64());
if (!senderPendingEvents) return [];
const sessionPendingEvents = senderPendingEvents.get(session.sessionId);
if (!sessionPendingEvents) return [];
const roomId = session.roomId.toString();
return [...sessionPendingEvents].filter((ev) => ev.getRoomId() === roomId);
}
/**
* Add an event to the list of those awaiting their session keys.
*/
private addEventToPendingList(event: MatrixEvent): void {
const content = event.getWireContent();
const senderKey = content.sender_key;
const sessionId = content.session_id;
const senderPendingEvents = this.eventsPendingKey.getOrCreate(senderKey);
const sessionPendingEvents = senderPendingEvents.getOrCreate(sessionId);
sessionPendingEvents.add(event);
}
/**
* Remove an event from the list of those awaiting their session keys.
*/
private removeEventFromPendingList(event: MatrixEvent): void {
const content = event.getWireContent();
const senderKey = content.sender_key;
const sessionId = content.session_id;
const senderPendingEvents = this.eventsPendingKey.get(senderKey);
if (!senderPendingEvents) return;
const sessionPendingEvents = senderPendingEvents.get(sessionId);
if (!sessionPendingEvents) return;
sessionPendingEvents.delete(event);
// also clean up the higher-level maps if they are now empty
if (sessionPendingEvents.size === 0) {
senderPendingEvents.delete(sessionId);
if (senderPendingEvents.size === 0) {
this.eventsPendingKey.delete(senderKey);
}
}
}
}
+8 -9
View File
@@ -18,11 +18,10 @@ limitations under the License.
* This is an internal module which manages queuing, scheduling and retrying
* of requests.
*/
import * as utils from "./utils";
import { logger } from "./logger";
import { MatrixEvent } from "./models/event";
import { EventType } from "./@types/event";
import { IDeferred } from "./utils";
import { defer, IDeferred, removeElement } from "./utils";
import { ConnectionError, MatrixError } from "./http-api";
import { ISendEventResponse } from "./@types/requests";
@@ -175,7 +174,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
return false;
}
let removed = false;
utils.removeElement(this.queues[name], (element) => {
removeElement(this.queues[name], (element) => {
if (element.event.getId() === event.getId()) {
// XXX we should probably reject the promise?
// https://github.com/matrix-org/matrix-js-sdk/issues/496
@@ -214,15 +213,15 @@ export class MatrixScheduler<T = ISendEventResponse> {
if (!this.queues[queueName]) {
this.queues[queueName] = [];
}
const defer = utils.defer<T>();
const deferred = defer<T>();
this.queues[queueName].push({
event: event,
defer: defer,
defer: deferred,
attempts: 0,
});
debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
this.startProcessingQueues();
return defer.promise;
return deferred.promise;
}
private startProcessingQueues(): void {
@@ -282,7 +281,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
);
if (waitTimeMs === -1) {
// give up (you quitter!)
debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId());
logger.info("Queue '%s' giving up on event %s", queueName, obj.event.getId());
// remove this from the queue
this.clearQueue(queueName, err);
} else {
@@ -298,11 +297,11 @@ export class MatrixScheduler<T = ISendEventResponse> {
if (index >= 0) {
this.activeQueues.splice(index, 1);
}
debuglog("Stopping queue '%s' as it is now empty", queueName);
logger.info("Stopping queue '%s' as it is now empty", queueName);
}
private clearQueue(queueName: string, err: unknown): void {
debuglog("clearing queue '%s'", queueName);
logger.info("clearing queue '%s'", queueName);
let obj: IQueueEntry<T> | undefined;
while ((obj = this.removeNextEvent(queueName))) {
obj.defer.reject(err);
+586
View File
@@ -20,6 +20,15 @@ limitations under the License.
* @see https://spec.matrix.org/v1.6/client-server-api/#storage
*/
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ClientEvent, ClientEventHandlerMap } from "./client";
import { MatrixEvent } from "./models/event";
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./crypto/aes";
import { randomString } from "./randomstring";
import { logger } from "./logger";
export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
/**
* Common base interface for Secret Storage Keys.
*
@@ -86,3 +95,580 @@ export interface PassphraseInfo {
/** The number of bits to generate. Defaults to 256. */
bits?: number;
}
/**
* Options for {@link ServerSideSecretStorageImpl#addKey}.
*/
export interface AddSecretStorageKeyOpts {
pubkey?: string;
passphrase?: PassphraseInfo;
name?: string;
key?: Uint8Array;
}
/**
* Return type for {@link ServerSideSecretStorageImpl#getKey}.
*/
export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription];
/**
* Return type for {@link ServerSideSecretStorageImpl#addKey}.
*/
export type SecretStorageKeyObject = {
/** The ID of the key */
keyId: string;
/** details about the key */
keyInfo: SecretStorageKeyDescription;
};
/** Interface for managing account data on the server.
*
* A subset of {@link MatrixClient}.
*/
export interface AccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> {
/**
* Get account data event of given type for the current user. This variant
* gets account data directly from the homeserver if the local store is not
* ready, which can be useful very early in startup before the initial sync.
*
* @param eventType - The type of account data
* @returns The contents of the given account data event, or `null` if the event is not found
*/
getAccountDataFromServer: <T extends Record<string, any>>(eventType: string) => Promise<T | null>;
/**
* Set account data event for the current user, with retries
*
* @param eventType - The type of account data
* @param content - the content object to be set
* @returns an empty object
*/
setAccountData: (eventType: string, content: any) => Promise<{}>;
}
/**
* Application callbacks for use with {@link SecretStorage.ServerSideSecretStorageImpl}
*/
export interface SecretStorageCallbacks {
/**
* Called to retrieve a secret storage encryption key
*
* Before a secret can be stored in server-side storage, it must be encrypted with one or more
* keys. Similarly, after it has been retrieved from storage, it must be decrypted with one of
* the keys it was encrypted with. These encryption keys are known as "secret storage keys".
*
* Descriptions of the secret storage keys are also stored in server-side storage, per the
* [matrix specification](https://spec.matrix.org/v1.6/client-server-api/#key-storage), so
* before a key can be used in this way, it must have been stored on the server. This is
* done via {@link SecretStorage.ServerSideSecretStorage#addKey}.
*
* Obviously the keys themselves are not stored server-side, so the js-sdk calls this callback
* in order to retrieve a secret storage key from the application.
*
* @param keys - An options object, containing only the property `keys`.
*
* @param name - the name of the *secret* (NB: not the encryption key) being stored or retrieved.
* This is the "event type" stored in account data.
*
* @returns a pair [`keyId`, `privateKey`], where `keyId` is one of the keys from the `keys` parameter,
* and `privateKey` is the raw private encryption key, as appropriate for the encryption algorithm.
* (For `m.secret_storage.v1.aes-hmac-sha2`, it is the input to an HKDF as defined in the
* [specification](https://spec.matrix.org/v1.6/client-server-api/#msecret_storagev1aes-hmac-sha2).)
*
* Alternatively, if none of the keys are known, may return `null` in which case the original
* storage/retrieval operation will fail with an exception.
*/
getSecretStorageKey?: (
keys: {
/**
* details of the secret storage keys required: a map from the key ID
* (excluding the `m.secret_storage.key.` prefix) to details of the key.
*
* When storing a secret, `keys` will contain exactly one entry; this method will be called
* once for each secret storage key to be used for encryption.
*
* For secret retrieval, `keys` may contain several entries, and the application can return
* any one of the requested keys.
*/
keys: Record<string, SecretStorageKeyDescription>;
},
name: string,
) => Promise<[string, Uint8Array] | null>;
}
interface SecretInfo {
encrypted: {
[keyId: string]: IEncryptedPayload;
};
}
interface Decryptors {
encrypt: (plaintext: string) => Promise<IEncryptedPayload>;
decrypt: (ciphertext: IEncryptedPayload) => Promise<string>;
}
/**
* Interface provided by SecretStorage implementations
*
* Normally this will just be an {@link ServerSideSecretStorageImpl}, but for backwards
* compatibility some methods allow other implementations.
*/
export interface ServerSideSecretStorage {
/**
* Add a key for encrypting secrets.
*
* @param algorithm - the algorithm used by the key.
* @param opts - the options for the algorithm. The properties used
* depend on the algorithm given.
* @param keyId - the ID of the key. If not given, a random
* ID will be generated.
*
* @returns details about the key.
*/
addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject>;
/**
* Get the key information for a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns If the key was found, the return value is an array of
* the form [keyId, keyInfo]. Otherwise, null is returned.
* XXX: why is this an array when addKey returns an object?
*/
getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null>;
/**
* Check whether we have a key with a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns Whether we have the key.
*/
hasKey(keyId?: string): Promise<boolean>;
/**
* Check whether a key matches what we expect based on the key info
*
* @param key - the key to check
* @param info - the key info
*
* @returns whether or not the key matches
*/
checkKey(key: Uint8Array, info: SecretStorageKeyDescriptionAesV1): Promise<boolean>;
/**
* Store an encrypted secret on the server.
*
* Details of the encryption keys to be used must previously have been stored in account data
* (for example, via {@link ServerSideSecretStorage#addKey}.
*
* @param name - The name of the secret - i.e., the "event type" to be stored in the account data
* @param secret - The secret contents.
* @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key
* (will throw if no default key is set).
*/
store(name: string, secret: string, keys?: string[] | null): Promise<void>;
/**
* Get a secret from storage, and decrypt it.
*
* @param name - the name of the secret - i.e., the "event type" stored in the account data
*
* @returns the decrypted contents of the secret, or "undefined" if `name` is not found in
* the user's account data.
*/
get(name: string): Promise<string | undefined>;
/**
* Check if a secret is stored on the server.
*
* @param name - the name of the secret
*
* @returns map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*/
isStored(name: string): Promise<Record<string, SecretStorageKeyDescriptionAesV1> | null>;
/**
* Get the current default key ID for encrypting secrets.
*
* @returns The default key ID or null if no default key ID is set
*/
getDefaultKeyId(): Promise<string | null>;
/**
* Set the default key ID for encrypting secrets.
*
* @param keyId - The new default key ID
*/
setDefaultKeyId(keyId: string): Promise<void>;
}
/**
* Implementation of Server-side secret storage.
*
* Secret *sharing* is *not* implemented here: this class is strictly about the storage component of
* SSSS.
*
* @see https://spec.matrix.org/v1.6/client-server-api/#storage
*/
export class ServerSideSecretStorageImpl implements ServerSideSecretStorage {
/**
* Construct a new `SecretStorage`.
*
* Normally, it is unnecessary to call this directly, since MatrixClient automatically constructs one.
* However, it may be useful to construct a new `SecretStorage`, if custom `callbacks` are required, for example.
*
* @param accountDataAdapter - interface for fetching and setting account data on the server. Normally an instance
* of {@link MatrixClient}.
* @param callbacks - application level callbacks for retrieving secret keys
*/
public constructor(
private readonly accountDataAdapter: AccountDataClient,
private readonly callbacks: SecretStorageCallbacks,
) {}
/**
* Get the current default key ID for encrypting secrets.
*
* @returns The default key ID or null if no default key ID is set
*/
public async getDefaultKeyId(): Promise<string | null> {
const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>(
"m.secret_storage.default_key",
);
if (!defaultKey) return null;
return defaultKey.key;
}
/**
* Set the default key ID for encrypting secrets.
*
* @param keyId - The new default key ID
*/
public setDefaultKeyId(keyId: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const listener = (ev: MatrixEvent): void => {
if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
resolve();
}
};
this.accountDataAdapter.on(ClientEvent.AccountData, listener);
this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => {
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
reject(e);
});
});
}
/**
* Add a key for encrypting secrets.
*
* @param algorithm - the algorithm used by the key.
* @param opts - the options for the algorithm. The properties used
* depend on the algorithm given.
* @param keyId - the ID of the key. If not given, a random
* ID will be generated.
*
* @returns An object with:
* keyId: the ID of the key
* keyInfo: details about the key (iv, mac, passphrase)
*/
public async addKey(
algorithm: string,
opts: AddSecretStorageKeyOpts = {},
keyId?: string,
): Promise<SecretStorageKeyObject> {
if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
throw new Error(`Unknown key algorithm ${algorithm}`);
}
const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1;
if (opts.name) {
keyInfo.name = opts.name;
}
if (opts.passphrase) {
keyInfo.passphrase = opts.passphrase;
}
if (opts.key) {
const { iv, mac } = await calculateKeyCheck(opts.key);
keyInfo.iv = iv;
keyInfo.mac = mac;
}
// Create a unique key id. XXX: this is racey.
if (!keyId) {
do {
keyId = randomString(32);
} while (
await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
`m.secret_storage.key.${keyId}`,
)
);
}
await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
return {
keyId,
keyInfo,
};
}
/**
* Get the key information for a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns If the key was found, the return value is an array of
* the form [keyId, keyInfo]. Otherwise, null is returned.
* XXX: why is this an array when addKey returns an object?
*/
public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
if (!keyId) {
keyId = await this.getDefaultKeyId();
}
if (!keyId) {
return null;
}
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
"m.secret_storage.key." + keyId,
);
return keyInfo ? [keyId, keyInfo] : null;
}
/**
* Check whether we have a key with a given ID.
*
* @param keyId - The ID of the key to check
* for. Defaults to the default key ID if not provided.
* @returns Whether we have the key.
*/
public async hasKey(keyId?: string): Promise<boolean> {
const key = await this.getKey(keyId);
return Boolean(key);
}
/**
* Check whether a key matches what we expect based on the key info
*
* @param key - the key to check
* @param info - the key info
*
* @returns whether or not the key matches
*/
public async checkKey(key: Uint8Array, info: SecretStorageKeyDescriptionAesV1): Promise<boolean> {
if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (info.mac) {
const { mac } = await calculateKeyCheck(key, info.iv);
return trimTrailingEquals(info.mac) === trimTrailingEquals(mac);
} else {
// if we have no information, we have to assume the key is right
return true;
}
} else {
throw new Error("Unknown algorithm");
}
}
/**
* Store an encrypted secret on the server.
*
* Details of the encryption keys to be used must previously have been stored in account data
* (for example, via {@link ServerSideSecretStorageImpl#addKey}. {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
* key to decrypt the secret.
*
* @param name - The name of the secret - i.e., the "event type" to be stored in the account data
* @param secret - The secret contents.
* @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key.
*/
public async store(name: string, secret: string, keys?: string[] | null): Promise<void> {
const encrypted: Record<string, IEncryptedPayload> = {};
if (!keys) {
const defaultKeyId = await this.getDefaultKeyId();
if (!defaultKeyId) {
throw new Error("No keys specified and no default key present");
}
keys = [defaultKeyId];
}
if (keys.length === 0) {
throw new Error("Zero keys given to encrypt with!");
}
for (const keyId of keys) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
"m.secret_storage.key." + keyId,
);
if (!keyInfo) {
throw new Error("Unknown key: " + keyId);
}
// encrypt secret, based on the algorithm
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const keys = { [keyId]: keyInfo };
const [, encryption] = await this.getSecretStorageKey(keys, name);
encrypted[keyId] = await encryption.encrypt(secret);
} else {
logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm);
// do nothing if we don't understand the encryption algorithm
}
}
// save encrypted secret
await this.accountDataAdapter.setAccountData(name, { encrypted });
}
/**
* Get a secret from storage, and decrypt it.
*
* {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
* key to decrypt the secret.
*
* @param name - the name of the secret - i.e., the "event type" stored in the account data
*
* @returns the decrypted contents of the secret, or "undefined" if `name` is not found in
* the user's account data.
*/
public async get(name: string): Promise<string | undefined> {
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretInfo>(name);
if (!secretInfo) {
return;
}
if (!secretInfo.encrypted) {
throw new Error("Content is not encrypted!");
}
// get possible keys to decrypt
const keys: Record<string, SecretStorageKeyDescriptionAesV1> = {};
for (const keyId of Object.keys(secretInfo.encrypted)) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
"m.secret_storage.key." + keyId,
);
const encInfo = secretInfo.encrypted[keyId];
// only use keys we understand the encryption algorithm of
if (keyInfo?.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
keys[keyId] = keyInfo;
}
}
}
if (Object.keys(keys).length === 0) {
throw new Error(
`Could not decrypt ${name} because none of ` +
`the keys it is encrypted with are for a supported algorithm`,
);
}
// fetch private key from app
const [keyId, decryption] = await this.getSecretStorageKey(keys, name);
const encInfo = secretInfo.encrypted[keyId];
return decryption.decrypt(encInfo);
}
/**
* Check if a secret is stored on the server.
*
* @param name - the name of the secret
*
* @returns map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*/
public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescriptionAesV1> | null> {
// check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretInfo>(name);
if (!secretInfo?.encrypted) return null;
const ret: Record<string, SecretStorageKeyDescriptionAesV1> = {};
// filter secret encryption keys with supported algorithm
for (const keyId of Object.keys(secretInfo.encrypted)) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
"m.secret_storage.key." + keyId,
);
if (!keyInfo) continue;
const encInfo = secretInfo.encrypted[keyId];
// only use keys we understand the encryption algorithm of
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
ret[keyId] = keyInfo;
}
}
}
return Object.keys(ret).length ? ret : null;
}
private async getSecretStorageKey(
keys: Record<string, SecretStorageKeyDescriptionAesV1>,
name: string,
): Promise<[string, Decryptors]> {
if (!this.callbacks.getSecretStorageKey) {
throw new Error("No getSecretStorageKey callback supplied");
}
const returned = await this.callbacks.getSecretStorageKey({ keys }, name);
if (!returned) {
throw new Error("getSecretStorageKey callback returned falsey");
}
if (returned.length < 2) {
throw new Error("getSecretStorageKey callback returned invalid data");
}
const [keyId, privateKey] = returned;
if (!keys[keyId]) {
throw new Error("App returned unknown key from getSecretStorageKey!");
}
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const decryption = {
encrypt: function (secret: string): Promise<IEncryptedPayload> {
return encryptAES(secret, privateKey, name);
},
decrypt: function (encInfo: IEncryptedPayload): Promise<string> {
return decryptAES(encInfo, privateKey, name);
},
};
return [keyId, decryption];
} else {
throw new Error("Unknown key type: " + keys[keyId].algorithm);
}
}
}
/** trim trailing instances of '=' from a string
*
* @internal
*
* @param input - input string
*/
export function trimTrailingEquals(input: string): string {
// according to Sonar and CodeQL, a regex such as /=+$/ is superlinear.
// Not sure I believe it, but it's easy enough to work around.
// find the number of characters before the trailing =
let i = input.length;
while (i >= 1 && input.charCodeAt(i - 1) == 0x3d) i--;
// trim to the calculated length
if (i < input.length) {
return input.substring(0, i);
} else {
return input;
}
}
+11 -25
View File
@@ -17,7 +17,7 @@ limitations under the License.
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
import { logger } from "./logger";
import * as utils from "./utils";
import { promiseMapSeries } from "./utils";
import { EventTimeline } from "./models/event-timeline";
import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
import {
@@ -85,30 +85,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
public async onResponse(data: ExtensionE2EEResponse): Promise<void> {
// Handle device list updates
if (data["device_lists"]) {
await this.crypto.handleDeviceListChanges(
{
oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :(
},
data["device_lists"],
);
if (data.device_lists) {
await this.crypto.processDeviceLists(data.device_lists);
}
// Handle one_time_keys_count
if (data["device_one_time_keys_count"]) {
const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0;
this.crypto.updateOneTimeKeyCount(currentCount);
}
if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) {
// The presence of device_unused_fallback_key_types indicates that the
// server supports fallback keys. If there's no unused
// signed_curve25519 fallback key we need a new one.
const unusedFallbackKeys =
data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"];
this.crypto.setNeedsNewFallback(
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
);
}
// Handle one_time_keys_count and unused_fallback_key_types
await this.crypto.processKeyCounts(
data.device_one_time_keys_count,
data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"],
);
this.crypto.onSyncCompleted({});
}
}
@@ -740,8 +726,8 @@ export class SlidingSyncSdk {
}
};
await utils.promiseMapSeries(stateEvents, processRoomEvent);
await utils.promiseMapSeries(timelineEvents, processRoomEvent);
await promiseMapSeries(stateEvents, processRoomEvent);
await promiseMapSeries(timelineEvents, processRoomEvent);
ephemeralEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
+1 -1
View File
@@ -176,7 +176,7 @@ export interface IStore {
/**
* Save does nothing as there is no backing data store.
*/
save(force?: boolean): void;
save(force?: boolean): Promise<void>;
/**
* Startup does nothing.
+10 -10
View File
@@ -15,8 +15,8 @@ limitations under the License.
*/
import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../sync-accumulator";
import * as utils from "../utils";
import * as IndexedDBHelpers from "../indexeddb-helpers";
import { deepCopy, promiseTry } from "../utils";
import { exists as idbExists } from "../indexeddb-helpers";
import { logger } from "../logger";
import { IStateEventWithRoomId, IStoredClientOpts } from "../matrix";
import { ISavedSync } from "./index";
@@ -122,7 +122,7 @@ function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> {
export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
dbName = "matrix-js-sdk:" + (dbName || "default");
return IndexedDBHelpers.exists(indexedDB, dbName);
return idbExists(indexedDB, dbName);
}
private readonly dbName: string;
@@ -380,7 +380,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
if (copy) {
// We must deep copy the stored data so that the /sync processing code doesn't
// corrupt the internal state of the sync accumulator (it adds non-clonable keys)
return Promise.resolve(utils.deepCopy(data));
return Promise.resolve(deepCopy(data));
} else {
return Promise.resolve(data);
}
@@ -435,7 +435,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
*/
private persistSyncData(nextBatch: string, roomsData: ISyncResponse["rooms"]): Promise<void> {
logger.log("Persisting sync data up to", nextBatch);
return utils.promiseTry<void>(() => {
return promiseTry<void>(() => {
const txn = this.db!.transaction(["sync"], "readwrite");
const store = txn.objectStore("sync");
store.put({
@@ -456,7 +456,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns Promise which resolves if the events were persisted.
*/
private persistAccountData(accountData: IMinimalEvent[]): Promise<void> {
return utils.promiseTry<void>(() => {
return promiseTry<void>(() => {
const txn = this.db!.transaction(["accountData"], "readwrite");
const store = txn.objectStore("accountData");
for (const event of accountData) {
@@ -475,7 +475,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns Promise which resolves if the users were persisted.
*/
private persistUserPresenceEvents(tuples: UserTuple[]): Promise<void> {
return utils.promiseTry<void>(() => {
return promiseTry<void>(() => {
const txn = this.db!.transaction(["users"], "readwrite");
const store = txn.objectStore("users");
for (const tuple of tuples) {
@@ -495,7 +495,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns A list of presence events in their raw form.
*/
public getUserPresenceEvents(): Promise<UserTuple[]> {
return utils.promiseTry<UserTuple[]>(() => {
return promiseTry<UserTuple[]>(() => {
const txn = this.db!.transaction(["users"], "readonly");
const store = txn.objectStore("users");
return selectQuery(store, undefined, (cursor) => {
@@ -510,7 +510,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
*/
private loadAccountData(): Promise<IMinimalEvent[]> {
logger.log(`LocalIndexedDBStoreBackend: loading account data...`);
return utils.promiseTry<IMinimalEvent[]>(() => {
return promiseTry<IMinimalEvent[]>(() => {
const txn = this.db!.transaction(["accountData"], "readonly");
const store = txn.objectStore("accountData");
return selectQuery(store, undefined, (cursor) => {
@@ -528,7 +528,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
*/
private loadSyncData(): Promise<ISyncData> {
logger.log(`LocalIndexedDBStoreBackend: loading sync data...`);
return utils.promiseTry<ISyncData>(() => {
return promiseTry<ISyncData>(() => {
const txn = this.db!.transaction(["sync"], "readonly");
const store = txn.objectStore("sync");
return selectQuery(store, undefined, (cursor) => {
+3 -1
View File
@@ -324,7 +324,9 @@ export class MemoryStore implements IStore {
* @param force - True to force a save (but the memory
* store still can't save anything)
*/
public save(force: boolean): void {}
public save(force: boolean): Promise<void> {
return Promise.resolve();
}
/**
* Startup does nothing as this store doesn't require starting up.
+3 -1
View File
@@ -189,7 +189,9 @@ export class StubStore implements IStore {
/**
* Save does nothing as there is no backing data store.
*/
public save(): void {}
public save(): Promise<void> {
return Promise.resolve();
}
/**
* Startup does nothing.

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