Compare commits

..

116 Commits

Author SHA1 Message Date
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
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
RiotRobot 7d45947fb3 v24.1.0 2023-04-11 13:59:08 +01:00
RiotRobot 6e174328e8 Prepare changelog for v24.1.0 2023-04-11 13:59:05 +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
RiotRobot 3f97853011 v24.1.0-rc.1 2023-04-04 11:53:22 +01:00
RiotRobot 2a5c4b1edf Prepare changelog for v24.1.0-rc.1 2023-04-04 11:53:18 +01:00
texuf 65a3c6707c indexddb-local-backend - return the current sync to database promise if a sync is in flight (#3222)
I’m trying to shutdown my matrix clients while using an indexdb, but awaiting the save() function has no effect because a previous sync was in flight. I ended up deleting the matrix client while the save was in flight and I saw a crash.

signed-off-by Austin Ellis <austin@hntlabs.com>

fix linter
2023-04-04 09:36:44 +00:00
Enrico Schwendig 942477f0bf screen share: remove set stream (#3238)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-04 08:53:32 +00:00
Michael Telatynski 1770b3131a Skip sonarcloud & coverage in merge_queue (#3247) 2023-04-04 08:46:41 +01:00
Richard van der Hoff 41d3ffdab9 Split up, rename, and move ISecretStorageKeyInfo (#3242)
* Move SecretStorageKeyInfo interfaces out to a new module

* Replace usages of ISecretStorageKeyInfo with SecretStorageKeyDescription
2023-04-03 10:11:03 +00:00
Richard van der Hoff 78aa6cb62b Point typedoc at matrix.ts, not index.ts (#3239)
This gets rid of the rather pointless "default" module in the generated docs.
2023-04-03 09:55:11 +00:00
Michael Telatynski b56b8040e6 Use frozen lockfile instead of pure lockfile on yarn install (#3245) 2023-03-31 14:49:49 +00:00
Andy Balaam d1cf98b177 Allow via_servers property in findPredecessor (update to MSC3946) (#3240) 2023-03-31 12:51:10 +00:00
Michael Telatynski 5fc6b3ed17 Fire closed event when IndexedDB closes unexpectedly (#3218)
* Fire `closed` event when IndexedDB closes unexpectedly

* Add test

* Add tests

* Add test

* Add test

* Add test coverage
2023-03-31 08:46:11 +00:00
renovate[bot] 6e41533fed Update jest monorepo to v29.5.0 (#3204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-31 07:52:07 +00:00
David Baker bc76532bd5 Refactor the way group calls hang up (#3234)
* Refactor how group call end calls

We previously used disposeCall to terminate the call which meant that
sometimes a call would never get a hangup event. This changes it so
that we always end a call by calling hangup, then do the cleanup
when the hangup event arrives, so the cleanup is the same whether
we hang up or the other side does.

* Some fixes for failing & hanging tests

* Add type for the call map
2023-03-30 15:57:47 +00:00
Enrico Schwendig 62f1dd79bc Add webrtc stats in full mesh calls (#3232)
* stats: merge stats classes in this branch

* stats: merge call object

* stats: merge export metric events

* stats: fix code style changes

* stats: add missing stats value formatter

* stats: add missing methode to call mock

* stats: add stats for callee

* stats: stop sending stats if call finish

* stats: rename StatsCollector to StatsReportGatherer
2023-03-30 15:17:59 +00:00
David Baker fd3f53e814 Merge pull request #3237 from matrix-org/dbkr/call_events_pass_call_2
Re-apply "Add the call object to Call events"
2023-03-30 10:22:17 +01:00
David Baker 798ac7b94c Revert "Revert "Add the call object to Call events"" 2023-03-29 15:32:25 +01:00
Richard van der Hoff eb0c0f7b93 Element-R: Add support for /discardsession (#3209)
Fixes https://github.com/vector-im/element-web/issues/24431
2023-03-29 13:26:02 +00:00
David Baker f03293f53d Merge pull request #3236 from matrix-org/revert-3229-dbkr/call_events_pass_call
Revert "Add the call object to Call events"
2023-03-29 14:41:59 +01:00
David Baker 7d062387b7 Revert "Add the call object to Call events" 2023-03-29 14:27:49 +01:00
Andy Balaam 9acf3b18ca Update changelog for v24.0.0 now the security issue is public (#3235) 2023-03-29 13:06:32 +00:00
David Baker b41d067c94 Merge pull request #3229 from matrix-org/dbkr/call_events_pass_call
Add the call object to Call events
2023-03-29 12:26:34 +01:00
David Baker c8503b3120 Run prettier on groupCall.ts (#3233) 2023-03-28 15:35:00 +00:00
David Baker da03c3b529 Fix an issue where participants could potentially view video without being displayed themselves
Fix from @robintown, manual merge due to Github having issues.
2023-03-28 15:20:34 +01:00
Robin d48b19e052 Handle group call redaction (#3231)
Redacted group call events should be interpreted as terminated calls.
2023-03-28 13:07:44 +00:00
RiotRobot 6861c67f56 Merge branch 'master' into develop 2023-03-28 14:15:09 +01:00
RiotRobot c87048bd9f v24.0.0 2023-03-28 14:10:53 +01:00
RiotRobot 02269f33b7 Prepare changelog for v24.0.0 2023-03-28 14:10:50 +01:00
Michael Weimann 7f46ae7b97 Further changes for v24.0.0 2023-03-28 14:05:24 +01:00
David Baker 2ab3566f95 Add comment on confusing exception handler I've failed to add a test for 2023-03-28 13:47:22 +01:00
Michael Weimann 9a504af18e Changes for v24.0.0 2023-03-28 11:22:02 +01:00
David Baker 8b50986906 Add test for incoming data channel 2023-03-28 09:30:17 +01:00
David Baker f9a222ecea Mock out media getter after setting up the vocie call
Otherwise we won't get far enough to test the right part
2023-03-27 16:18:00 +01:00
David Baker 5b5a3d8b5e Test failed call upgrade 2023-03-27 15:44:56 +01:00
David Baker 037cbdd214 Basic test for call replace 2023-03-27 14:33:58 +01:00
David Baker 0349411e6d Rename group call error event
So it doesn't clash with the call error event
2023-03-24 14:26:03 +00:00
David Baker d7b75e4b9e Add the call object to Call events
As explained in the comment. I've added it to the end so this should
be completely backwards compatible (although it would be much nicer
if it were the first arg, probably).
2023-03-24 14:09:22 +00:00
Richard van der Hoff 254f043ab0 Enable the prepareToEncrypt test for Element-R (#3225)
This Just Works, so I don't know why I didn't enable the test sooner.
2023-03-24 11:55:17 +00:00
J. Ryan Stinnett 5f3e115545 Stop doing O(n^2) work to find event's home (#3227)
* Stop doing O(n^2) work to find event's home

In certain rooms (e.g. with many state changes hidden via user preferences), the
events array presented to `eventShouldLiveIn` may contain 100s of events. As
part of its various checks, `eventShouldLiveIn` would get an event's associated
ID (reply / relation / redaction parent). It would then use `events.find` to
search the entire (possibly large) `events` array to look for the parent. (This
by itself seems sub-optimal and should probably change to use a map.)

For many events in a room, there is no associated ID. Unfortunately,
`eventShouldLiveIn` did not check whether the associated ID actually exists
before running off to search all of `events`, resulting in O(n^2) work.

This changes `eventShouldLiveIn` to first check that there is an associated ID
before proceeding with its (slow) search. For some rooms, this change
drastically improves performance from ~100% CPU usage to nearly idle.

Signed-off-by: J. Ryan Stinnett <jryans@gmail.com>

* Add type to `parentEvent`

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

---------

Signed-off-by: J. Ryan Stinnett <jryans@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-03-23 19:02:55 +00:00
Patrick Cloke fc55c4c72a Implement MSC3952: intentional mentions (#3092)
* Add experimental push rules.

* Update for changes to MSC3952: Use event_property_is and event_property_contains.

* Revert custom user/room mention conditions.

* Skip legacy rule processing if mentions exist.

* Add client option for intentional mentions.

* Fix tests.

* Test leagcy behavior with intentional mentions.

* Handle simple review comments.
2023-03-22 20:22:34 +00:00
Florian Duros f795577e14 Send one time key count and unused fallback keys for rust-crypto (#3215)
* Send one time key count and unused fallback keys for rust-crypto

* Add tests

* Remove useless type in promise return

* Add test for one time key upload

* Fix rust-crypto.spec.ts tests

* Remove unneeded code in test

* Add key upload request test

* Fix tests
2023-03-22 10:19:04 +00:00
Eric Eastwood f12cee984a Export TimestampToEventResponse to use in matrix-react-sdk tests (#3223)
* Export ITimestampToEventResponse to use in matrix-react-sdk tests

Part of https://github.com/matrix-org/matrix-react-sdk/pull/10405

* Remove I from interface

As suggested by @weeman1337,
https://github.com/matrix-org/matrix-js-sdk/pull/3223#pullrequestreview-1350612347

See code style guide, https://github.com/vector-im/element-web/blob/50f8be4a623d2280a72920eb1679a4754961f807/code_style.md#typescript--javascript-typescript-javascript

> Interface names should not be marked with an uppercase `I`.
2023-03-21 17:10:28 +00:00
Richard van der Hoff c3b4572841 Add a new test for event encryption, which works with rust (#3203)
* crypto.spec.ts: factor out `expactAliceKeyClaim` utility

* Add a new test for event encryption

... one that actually works on the rust SDK.

* Bump matrix-sdk-crypto-js version

... to pick up recent fixes to race conditions
2023-03-21 16:24:57 +00:00
David Baker 40fe159c10 Merge pull request #3219 from robintown/matrix-widget-api
Update matrix-widget-api
2023-03-21 10:59:50 +00:00
Robin Townsend ddecc87947 Update matrix-widget-api 2023-03-20 10:28:46 -04:00
David Baker 23837266fc Merge pull request #3217 from matrix-org/dbkr/type_sendvoipevent
Add a type to the structure used by the SendVoipEvent event
2023-03-16 10:52:09 +00:00
David Baker 3c9ca8c373 Add a type to the structure used by the SendVoipEvent event 2023-03-15 16:07:10 +00:00
RiotRobot 7f2a4c2568 Resetting package fields for development 2023-03-15 12:41:24 +00:00
RiotRobot 2ad647a73c Merge branch 'master' into develop 2023-03-15 12:41:18 +00:00
David Baker 7faba5c2f0 Merge pull request #3213 from matrix-org/dbkr/fix_starting_with_video_muted
Fix bug where video would not unmute if it started muted
2023-03-14 11:18:39 +00:00
Michael Telatynski 1d9250b277 Fix CallOpts types (#3214) 2023-03-14 09:53:59 +00:00
David Baker 08054c1d6d Fix bug where video would not unmute if it started muted
Fixes https://github.com/vector-im/element-call/issues/925
2023-03-13 17:04:43 +00:00
Richard van der Hoff 333872e878 Pull out a public CryptoApi (#3211)
... so that we don't have to implement annoying shims in `MatrixClient`
2023-03-13 12:02:49 +00:00
Janne Mareike Koschinski 913cd257f4 Pin versions of third-party github actions (#3208) 2023-03-10 12:56:23 +00:00
Richard van der Hoff 69f7789c40 Merge pull request #3202 from matrix-org/rav/element-r/encryption_fixes
Fixes to event encryption in the Rust Crypto implementation
2023-03-10 11:00:18 +00:00
renovate[bot] e79ef1f33a Update typescript-eslint monorepo to v5.54.0 (#3198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-03-10 09:06:58 +00:00
renovate[bot] fb8f61a5ec Update dependency eslint-plugin-unicorn to v46 (#3199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-09 21:28:20 +00:00
Kerry 9b1f2a1d11 adjust beacon model to make beaconInfo definitely assigned in constructor (#3201)
* adjust beacon model to make beaconInfo definitely assigned

* fix

* improvement from PR
2023-03-09 21:03:42 +00:00
Richard van der Hoff 686216fb75 Merge branch 'develop' into rav/element-r/encryption_fixes 2023-03-09 13:34:17 +00:00
Richard van der Hoff f4b83e1a27 Stop a failed /keys/claim request getting stuck
Putting the new request inside a `finally` block meant we would never actually
transition the promise chain from failure to success. Sticking a no-op `catch`
in the chain makes sure that we can recover from an error.
2023-03-09 10:54:50 +00:00
Richard van der Hoff 97f21b6635 Send the outgoing m.room_key messages returned by shareRoomKey
I forgot this in https://github.com/matrix-org/matrix-js-sdk/pull/3122 :(.

To be honest, I'm not sure how it ever worked.
2023-03-09 10:50:49 +00:00
Michael Telatynski 87641a6803 Improve processBeaconEvents hotpath (#3200)
* Attempt at improving beacons hotpath

* Iterate and fix tests
2023-03-09 09:24:57 +00:00
renovate[bot] 7e4331172a chore(deps): update all non-major dependencies (#3197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-08 15:33:44 +00:00
renovate[bot] a976080d1b chore(deps): update dependency @types/node to v18.14.6 (#3196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-08 08:16:55 +00:00
Patrick Cloke bcf3bba44e Implement MSC3966: a push rule condition to check if an array contains a value (#3180)
* Support MSC3966 to match values in an array in push rule conditions.

* Update to stable identifiers.

* Appease the linter.
2023-03-07 16:36:06 +00:00
Michael Telatynski 54ac36d424 Revert "Add GHA for requiring all reviews to be dismissed or approving before merging (#2391)" (#3195)
This reverts commit e84c90dbbc.
2023-03-07 15:21:20 +00:00
Michael Telatynski e84c90dbbc Add GHA for requiring all reviews to be dismissed or approving before merging (#2391)
* Add GHA for requiring all reviews to be dismissed or approving before merging

* Attempt again

* Try try try again

* stash

* Stash

* Dummy

* Fix indentation

* Iterate

* Tweak

* Iterate

* toJSON

* Iterate

* Iterate

* Fix

* typo

* Permissions

* tidy

* Update pending_reviews.yml

* delint
2023-03-07 14:23:56 +00:00
Michael Telatynski 4424438658 Fix jest/no-conditional-expect lint and enable it (#3194) 2023-03-07 12:44:03 +00:00
146 changed files with 8324 additions and 2908 deletions
-3
View File
@@ -69,9 +69,6 @@ module.exports = {
// TODO: There are many tests with invalid expects that should be fixed,
// https://github.com/matrix-org/matrix-js-sdk/issues/2976
"jest/valid-expect": "off",
// TODO: There are many cases to refactor away,
// https://github.com/matrix-org/matrix-js-sdk/issues/2978
"jest/no-conditional-expect": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
)
)
steps:
- uses: tibdex/backport@v2
- uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2
with:
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
+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.26.0
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@v2
uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+2 -2
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
@@ -39,7 +39,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Check membership
uses: tspascoal/get-user-teams-membership@v2
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
+2 -2
View File
@@ -20,11 +20,11 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
run: "yarn install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@v1
uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
cache: "yarn"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
run: "yarn install --frozen-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
@@ -46,7 +46,7 @@ jobs:
fi
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
- uses: Sibz/github-status-action@v1
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -42,7 +42,7 @@ jobs:
coverage_extract_path: coverage
extra_args: ${{ inputs.extra_args }}
- uses: Sibz/github-status-action@v1
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+3 -1
View File
@@ -11,6 +11,7 @@ jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.event != 'merge_group'
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
@@ -19,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@v2
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
@@ -35,6 +36,7 @@ jobs:
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.event != 'merge_group'
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
run: "yarn install"
- name: Generate Docs
run: "yarn run gendoc"
run: "yarn run gendoc --treatWarningsAsErrors"
- name: Upload Artifact
uses: actions/upload-artifact@v3
+23 -11
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 }})"
@@ -36,26 +38,24 @@ jobs:
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: Load metrics reporter
id: metrics
if: github.ref == 'refs/heads/develop'
run: |
echo "extra-reporter='--reporters=<rootDir>/spec/slowReporter.js'" >> $GITHUB_OUTPUT
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
- name: Run tests
run: |
yarn 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 }}
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v3
with:
name: coverage
@@ -71,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
@@ -79,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@v4
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
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@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 }}
+42
View File
@@ -1,3 +1,45 @@
Changes in [25.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0-rc.1) (2023-04-18)
============================================================================================================
## 🚨 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)
==================================================================================================
## ✨ Features
* Allow via_servers property in findPredecessor (update to MSC3946) ([\#3240](https://github.com/matrix-org/matrix-js-sdk/pull/3240)). Contributed by @andybalaam.
* Fire `closed` event when IndexedDB closes unexpectedly ([\#3218](https://github.com/matrix-org/matrix-js-sdk/pull/3218)).
* Implement MSC3952: intentional mentions ([\#3092](https://github.com/matrix-org/matrix-js-sdk/pull/3092)). Fixes vector-im/element-web#24376.
* Send one time key count and unused fallback keys for rust-crypto ([\#3215](https://github.com/matrix-org/matrix-js-sdk/pull/3215)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
* Improve `processBeaconEvents` hotpath ([\#3200](https://github.com/matrix-org/matrix-js-sdk/pull/3200)).
* Implement MSC3966: a push rule condition to check if an array contains a value ([\#3180](https://github.com/matrix-org/matrix-js-sdk/pull/3180)).
## 🐛 Bug Fixes
* indexddb-local-backend - return the current sync to database promise … ([\#3222](https://github.com/matrix-org/matrix-js-sdk/pull/3222)). Contributed by @texuf.
* Revert "Add the call object to Call events" ([\#3236](https://github.com/matrix-org/matrix-js-sdk/pull/3236)).
* Handle group call redaction ([\#3231](https://github.com/matrix-org/matrix-js-sdk/pull/3231)). Fixes vector-im/voip-internal#128.
* Stop doing O(n^2) work to find event's home (`eventShouldLiveIn`) ([\#3227](https://github.com/matrix-org/matrix-js-sdk/pull/3227)). Contributed by @jryans.
* Fix bug where video would not unmute if it started muted ([\#3213](https://github.com/matrix-org/matrix-js-sdk/pull/3213)). Fixes vector-im/element-call#925.
* Fixes to event encryption in the Rust Crypto implementation ([\#3202](https://github.com/matrix-org/matrix-js-sdk/pull/3202)).
Changes in [24.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.0.0) (2023-03-28)
==================================================================================================
## 🔒 Security
* Fixes for [CVE-2023-28427](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2023-28427) / GHSA-mwq8-fjpf-c2gr
Changes in [23.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.5.0) (2023-03-15)
==================================================================================================
+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;
+9 -24
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "23.5.0",
"version": "25.0.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -55,13 +55,13 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.6",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"matrix-widget-api": "^1.3.1",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
@@ -101,7 +101,7 @@
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.34.0",
"eslint": "8.37.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -110,7 +110,7 @@
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^45.0.0",
"eslint-plugin-unicorn": "^46.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
@@ -119,30 +119,15 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.4",
"prettier": "2.8.7",
"rimraf": "^4.0.0",
"terser": "^5.5.1",
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
"typedoc-plugin-mdn-links": "^3.0.3",
"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"
"typescript": "^5.0.0"
},
"@casualbot/jest-sonar-reporter": {
"outputDirectory": "coverage",
+2 -2
View File
@@ -130,7 +130,7 @@ fi
# global cache here to ensure we get the right thing.
yarn cache clean
# Ensure all dependencies are updated
yarn install --ignore-scripts --pure-lockfile
yarn install --ignore-scripts --frozen-lockfile
# ignore leading v on release
release="${1#v}"
@@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then
pushd "$builddir"
git clone "$projdir" .
git checkout "$rel_branch"
yarn install --pure-lockfile
yarn install --frozen-lockfile
# We haven't tagged yet, so tell the dist script what version
# it's building
DIST_VERSION="$tag" yarn dist
+195 -47
View File
@@ -19,7 +19,7 @@ import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { MockResponse } from "fetch-mock";
import { MockResponse, MockResponseFunction } from "fetch-mock";
import type { IDeviceKeys } from "../../src/@types/crypto";
import * as testUtils from "../test-utils/test-utils";
@@ -47,6 +47,7 @@ import {
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";
const ROOM_ID = "!room:id";
@@ -341,6 +342,11 @@ async function expectSendRoomKey(
resolve(onSendRoomKey(content));
return {};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
}
@@ -359,12 +365,20 @@ async function expectSendMegolmMessage(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await new Promise<IContent>((resolve) => {
fetchMock.putOnce(new RegExp("/send/m.room.encrypted/"), (url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
return {
event_id: "$event_id",
};
});
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
return {
event_id: "$event_id",
};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
@@ -438,8 +452,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
return response;
}
const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString());
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/query", aliceClient.getHomeserverUrl()).toString(),
new RegExp(rootRegexp + "(r0|v3)/keys/query"),
(url: string, opts: RequestInit) => onQueryRequest(JSON.parse(opts.body as string)),
{
// append to the list of intercepts on this path
@@ -448,6 +463,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
);
}
/**
* Add an expectation for a /keys/claim request for the MatrixClient under test
*
* @param response - the response to return from the request. Normally an {@link IClaimOTKsResult}
* (or a function that returns one).
*/
function expectAliceKeyClaim(response: MockResponse | MockResponseFunction) {
const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString());
fetchMock.postOnce(new RegExp(rootRegexp + "(r0|v3)/keys/claim"), response);
}
/**
* Get the device keys for testOlmAccount in a format suitable for a
* response to /keys/query
@@ -536,6 +562,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
fetchMock.mockReset();
});
it("MatrixClient.getCrypto returns a CryptoApi", () => {
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
});
it("Alice receives a megolm message", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -543,7 +573,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -594,16 +624,14 @@ 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.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -637,7 +665,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({
@@ -648,20 +680,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 () => {
@@ -671,7 +691,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -738,7 +758,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(event.getContent().body).toEqual("42");
});
oldBackendOnly("prepareToEncrypt", async () => {
it("prepareToEncrypt", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
aliceClient.setGlobalErrorOnUnknownDevices(false);
@@ -751,10 +771,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and then claim one of his OTKs
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(),
getTestKeysClaimResponse("@bob:xyz"),
);
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// fire off the prepare request
const room = aliceClient.getRoom(ROOM_ID);
@@ -768,7 +785,71 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await p;
});
it("Alice sends a megolm message with GlobalErrorOnUnknownDevices=false", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Once we send the message, Alice will check Bob's device list (twice, because reasons) ...
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and claim one of his OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room_key message
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
]);
});
it("We should start a new megolm session after forceDiscardSession", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Once we send the message, Alice will check Bob's device list (twice, because reasons) ...
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and claim one of his OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room_key message
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// Send the first message, and check we can decrypt it.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
]);
// Finally the interesting part: discard the session.
aliceClient.forceDiscardSession(ROOM_ID);
// Now when we send the next message, we should get a *new* megolm session.
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
});
oldBackendOnly("Alice sends a megolm message", async () => {
// TODO: do something about this for the rust backend.
// Currently it fails because we don't respect the default GlobalErrorOnUnknownDevices and
// send messages to unknown devices.
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
@@ -1027,20 +1108,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
throw new Error("sendTextMessage succeeded on an unknown device");
} catch (e) {
expect((e as any).name).toEqual("UnknownDeviceError");
expect(Object.keys((e as any).devices)).toEqual([aliceClient.getUserId()!]);
expect(Object.keys((e as any)?.devices[aliceClient.getUserId()!])).toEqual(["DEVICE_ID"]);
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID"));
}
// mark the device as known, and resend.
aliceClient.setDeviceKnown(aliceClient.getUserId()!, "DEVICE_ID");
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(),
(url: string, opts: RequestInit): MockResponse => {
const content = JSON.parse(opts.body as string);
expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519");
return getTestKeysClaimResponse(aliceClient.getUserId()!);
},
);
expectAliceKeyClaim((url: string, opts: RequestInit): MockResponse => {
const content = JSON.parse(opts.body as string);
expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519");
return getTestKeysClaimResponse(aliceClient.getUserId()!);
});
const inboundGroupSessionPromise = expectSendRoomKey(aliceClient.getUserId()!, testOlmAccount);
@@ -1099,7 +1177,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -1255,7 +1333,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -1322,7 +1400,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
aliceClient.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!;
}
@@ -1849,4 +1927,74 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(event.getContent().body).not.toContain("withheld");
});
});
describe("key upload request", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
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;
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,
},
};
};
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,
});
}
});
}
it("should make key upload request after sync", async () => {
let uploadPromise = awaitKeyUploadRequest();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
syncResponder.sendOrQueueSyncResponse(getSyncResponse([]));
await syncPromise(aliceClient);
// 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
// The old crypto only runs the upload every 60 seconds
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
await syncPromise(aliceClient);
// 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);
});
});
});
+34 -5
View File
@@ -603,14 +603,14 @@ describe("MatrixClient", function () {
});
const prom = client!.downloadKeys(["boris", "chaz"]).then(function (res) {
assertObjectContains(res.boris.dev1, {
assertObjectContains(res.get("boris")!.get("dev1")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev1": ed25519key },
algorithms: ["1"],
unsigned: { abc: "def" },
});
assertObjectContains(res.chaz.dev2, {
assertObjectContains(res.get("chaz")!.get("dev2")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev2": ed25519key },
algorithms: ["2"],
@@ -1127,22 +1127,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 });
});
});
+4 -8
View File
@@ -157,14 +157,10 @@ describe("MatrixClient opts", function () {
error: "Ruh roh",
}),
);
try {
await Promise.all([
expect(client.sendTextMessage("!foo:bar", "a body", "txn1")).rejects.toThrow(),
httpBackend.flush("/txn1", 1),
]);
} catch (err) {
expect((<MatrixError>err).errcode).toEqual("M_SOMETHING");
}
await expect(
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
).rejects.toThrow("MatrixError: [500] Unknown message");
});
it("shouldn't queue events", async () => {
+3 -3
View File
@@ -472,7 +472,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -483,7 +483,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -545,7 +545,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
+13 -26
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",
]);
});
});
@@ -891,11 +880,9 @@ describe("SlidingSyncSdk", () => {
const evType = ev.getType();
expect(seen[evType]).toBeFalsy();
seen[evType] = true;
if (evType === "m.key.verification.start" || evType === "m.key.verification.request") {
expect(ev.isCancelled()).toEqual(true);
} else {
expect(ev.isCancelled()).toEqual(false);
}
expect(ev.isCancelled()).toEqual(
evType === "m.key.verification.start" || evType === "m.key.verification.request",
);
});
ext.onResponse({
next_batch: "45678",
+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);
});
});
+145 -6
View File
@@ -30,6 +30,7 @@ import {
RoomState,
RoomStateEvent,
RoomStateEventHandlerMap,
SendToDeviceContentMap,
} from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
import { ReEmitter } from "../../src/ReEmitter";
@@ -122,6 +123,7 @@ export class MockRTCPeerConnection {
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
public iceConnectionStateChangeListener?: () => void;
public onTrackListener?: (e: RTCTrackEvent) => void;
public onDataChannelListener?: (ev: RTCDataChannelEvent) => void;
public needsNegotiation = false;
public readyToNegotiate: Promise<void>;
private onReadyToNegotiate?: () => void;
@@ -167,6 +169,8 @@ export class MockRTCPeerConnection {
this.iceConnectionStateChangeListener = listener;
} else if (type == "track") {
this.onTrackListener = listener;
} else if (type == "datachannel") {
this.onDataChannelListener = listener;
}
}
public createDataChannel(label: string, opts: RTCDataChannelInit) {
@@ -231,6 +235,10 @@ export class MockRTCPeerConnection {
this.negotiationNeededListener();
}
}
public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
}
}
export class MockRTCRtpSender {
@@ -443,11 +451,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
>();
public sendToDevice = jest.fn<
Promise<{}>,
[
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
]
[eventType: string, contentMap: SendToDeviceContentMap, txnId?: string]
>();
public isInitialSyncComplete(): boolean {
@@ -502,18 +506,22 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public state = CallState.Ringing;
public opponentUserId = FAKE_USER_ID_1;
public opponentDeviceId = FAKE_DEVICE_ID_1;
public opponentSessionId = FAKE_SESSION_ID_1;
public opponentMember = { userId: this.opponentUserId };
public callId = "1";
public localUsermediaFeed = {
setAudioVideoMuted: jest.fn<void, [boolean, boolean]>(),
isAudioMuted: jest.fn().mockReturnValue(false),
isVideoMuted: jest.fn().mockReturnValue(false),
stream: new MockMediaStream("stream"),
};
} as unknown as CallFeed;
public remoteUsermediaFeed?: CallFeed;
public remoteScreensharingFeed?: CallFeed;
public reject = jest.fn<void, []>();
public answerWithCallFeeds = jest.fn<void, [CallFeed[]]>();
public hangup = jest.fn<void, []>();
public initStats = jest.fn<void, []>();
public sendMetadataUpdate = jest.fn<void, []>();
@@ -525,6 +533,14 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
return this.opponentDeviceId;
}
public getOpponentSessionId(): string | undefined {
return this.opponentSessionId;
}
public getLocalFeeds(): CallFeed[] {
return [this.localUsermediaFeed];
}
public typed(): MatrixCall {
return this as unknown as MatrixCall;
}
@@ -585,6 +601,7 @@ export function makeMockGroupCallStateEvent(
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
},
redacted?: boolean,
): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix),
@@ -592,6 +609,7 @@ export function makeMockGroupCallStateEvent(
getTs: jest.fn().mockReturnValue(0),
getContent: jest.fn().mockReturnValue(content),
getStateKey: jest.fn().mockReturnValue(groupCallId),
isRedacted: jest.fn().mockReturnValue(redacted ?? false),
} as unknown as MatrixEvent;
}
@@ -604,3 +622,124 @@ export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: s
getStateKey: jest.fn().mockReturnValue(groupCallId),
} as unknown as MatrixEvent;
}
export const REMOTE_SFU_DESCRIPTION =
"v=0\n" +
"o=- 3242942315779688438 1678878001 IN IP4 0.0.0.0\n" +
"s=-\n" +
"t=0 0\n" +
"a=fingerprint:sha-256 EA:30:B2:7F:49:B5:46:D6:40:72:BF:79:95:C1:65:08:6E:35:09:FB:90:89:DA:EF:6B:82:D1:38:8C:25:39:B2\n" +
"a=group:BUNDLE 0 1 2\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 9 0 8\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:0\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:111 opus/48000/2\n" +
"a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1\n" +
"a=rtcp-fb:111 transport-cc \n" +
"a=rtpmap:9 G722/8000\n" +
"a=rtpmap:0 PCMU/8000\n" +
"a=rtpmap:8 PCMA/8000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=ssrc:2963372119 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=ssrc:2963372119 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 label:4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=sendrecv\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=end-of-candidates\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 106 108 109 98 99 112 116\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:1\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:96 VP8/90000\n" +
"a=rtcp-fb:96 goog-remb \n" +
"a=rtcp-fb:96 transport-cc \n" +
"a=rtcp-fb:96 ccm fir\n" +
"a=rtcp-fb:96 nack \n" +
"a=rtcp-fb:96 nack pli\n" +
"a=rtpmap:97 rtx/90000\n" +
"a=fmtp:97 apt=96\n" +
"a=rtpmap:102 H264/90000\n" +
"a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\n" +
"a=rtcp-fb:102 goog-remb \n" +
"a=rtcp-fb:102 transport-cc \n" +
"a=rtcp-fb:102 ccm fir\n" +
"a=rtcp-fb:102 nack \n" +
"a=rtcp-fb:102 nack pli\n" +
"a=rtpmap:103 rtx/90000\n" +
"a=fmtp:103 apt=102\n" +
"a=rtpmap:104 H264/90000\n" +
"a=fmtp:104 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\n" +
"a=rtcp-fb:104 goog-remb \n" +
"a=rtcp-fb:104 transport-cc \n" +
"a=rtcp-fb:104 ccm fir\n" +
"a=rtcp-fb:104 nack \n" +
"a=rtcp-fb:104 nack pli\n" +
"a=rtpmap:106 H264/90000\n" +
"a=fmtp:106 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
"a=rtcp-fb:106 goog-remb \n" +
"a=rtcp-fb:106 transport-cc \n" +
"a=rtcp-fb:106 ccm fir\n" +
"a=rtcp-fb:106 nack \n" +
"a=rtcp-fb:106 nack pli\n" +
"a=rtpmap:108 H264/90000\n" +
"a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\n" +
"a=rtcp-fb:108 goog-remb \n" +
"a=rtcp-fb:108 transport-cc \n" +
"a=rtcp-fb:108 ccm fir\n" +
"a=rtcp-fb:108 nack \n" +
"a=rtcp-fb:108 nack pli\n" +
"a=rtpmap:109 rtx/90000\n" +
"a=fmtp:109 apt=108\n" +
"a=rtpmap:98 VP9/90000\n" +
"a=fmtp:98 profile-id=0\n" +
"a=rtcp-fb:98 goog-remb \n" +
"a=rtcp-fb:98 transport-cc \n" +
"a=rtcp-fb:98 ccm fir\n" +
"a=rtcp-fb:98 nack \n" +
"a=rtcp-fb:98 nack pli\n" +
"a=rtpmap:99 rtx/90000\n" +
"a=fmtp:99 apt=98\n" +
"a=rtpmap:112 H264/90000\n" +
"a=fmtp:112 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\n" +
"a=rtcp-fb:112 goog-remb \n" +
"a=rtcp-fb:112 transport-cc \n" +
"a=rtcp-fb:112 ccm fir\n" +
"a=rtcp-fb:112 nack \n" +
"a=rtcp-fb:112 nack pli\n" +
"a=rtpmap:116 ulpfec/90000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
"a=rid:f recv\n" +
"a=rid:h recv\n" +
"a=rid:q recv\n" +
"a=simulcast:recv f;h;q\n" +
"a=ssrc:1212931603 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=ssrc:1212931603 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 label:12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=sendrecv\n" +
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:2\n" +
"a=sendrecv\n" +
"a=sctp-port:5000\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";
+2 -14
View File
@@ -46,13 +46,7 @@ describe("NamespacedValue", () => {
});
it("should not permit falsey values for both parts", () => {
try {
new UnstableValue(null!, null!);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toBe("One of stable or unstable values must be supplied");
}
expect(() => new UnstableValue(null!, null!)).toThrow("One of stable or unstable values must be supplied");
});
});
@@ -72,12 +66,6 @@ describe("UnstableValue", () => {
});
it("should not permit falsey unstable values", () => {
try {
new UnstableValue("stable", null!);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toBe("Unstable value must be supplied");
}
expect(() => new UnstableValue("stable", null!)).toThrow("Unstable value must be supplied");
});
});
+2 -3
View File
@@ -405,7 +405,7 @@ describe("Crypto", function () {
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
@@ -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);
@@ -1039,7 +1038,7 @@ describe("Crypto", function () {
beforeEach(async () => {
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
ensureOlmSessionsForDevices.mockResolvedValue({});
ensureOlmSessionsForDevices.mockResolvedValue(new Map());
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => {
result.plaintext = { type: 0, body: JSON.stringify(payload) };
+6 -5
View File
@@ -102,9 +102,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
// eslint-disable-next-line jest/no-conditional-expect
expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String));
}
},
);
@@ -115,10 +116,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
// eslint-disable-next-line jest/no-conditional-expect
expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey);
}
});
+58 -44
View File
@@ -34,6 +34,7 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
import { recursiveMapToObject } from "../../../../src/utils";
import { sleep } from "../../../../src/utils";
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
@@ -183,14 +184,22 @@ describe("MegolmDecryption", function () {
const deviceInfo = {} as DeviceInfo;
mockCrypto.getStoredDevice.mockReturnValue(deviceInfo);
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({
"@alice:foo": {
alidevice: {
sessionId: "alisession",
device: new DeviceInfo("alidevice"),
},
},
});
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue(
new Map([
[
"@alice:foo",
new Map([
[
"alidevice",
{
sessionId: "alisession",
device: new DeviceInfo("alidevice"),
},
],
]),
],
]),
);
const awaitEncryptForDevice = new Promise<void>((res, rej) => {
mockOlmLib.encryptMessageForDevice.mockImplementation(() => {
@@ -357,11 +366,7 @@ describe("MegolmDecryption", function () {
} as unknown as DeviceInfo;
mockCrypto.downloadKeys.mockReturnValue(
Promise.resolve({
"@alice:home.server": {
aliceDevice: aliceDeviceInfo,
},
}),
Promise.resolve(new Map([["@alice:home.server", new Map([["aliceDevice", aliceDeviceInfo]])]])),
);
mockCrypto.checkDeviceTrust.mockReturnValue({
@@ -523,23 +528,32 @@ describe("MegolmDecryption", function () {
let megolm: MegolmEncryptionClass;
let room: jest.Mocked<Room>;
const deviceMap: DeviceInfoMap = {
"user-a": {
"device-a": new DeviceInfo("device-a"),
"device-b": new DeviceInfo("device-b"),
"device-c": new DeviceInfo("device-c"),
},
"user-b": {
"device-d": new DeviceInfo("device-d"),
"device-e": new DeviceInfo("device-e"),
"device-f": new DeviceInfo("device-f"),
},
"user-c": {
"device-g": new DeviceInfo("device-g"),
"device-h": new DeviceInfo("device-h"),
"device-i": new DeviceInfo("device-i"),
},
};
const deviceMap: DeviceInfoMap = new Map([
[
"user-a",
new Map([
["device-a", new DeviceInfo("device-a")],
["device-b", new DeviceInfo("device-b")],
["device-c", new DeviceInfo("device-c")],
]),
],
[
"user-b",
new Map([
["device-d", new DeviceInfo("device-d")],
["device-e", new DeviceInfo("device-e")],
["device-f", new DeviceInfo("device-f")],
]),
],
[
"user-c",
new Map([
["device-g", new DeviceInfo("device-g")],
["device-h", new DeviceInfo("device-h")],
["device-i", new DeviceInfo("device-i")],
]),
],
]);
beforeEach(() => {
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
@@ -572,8 +586,8 @@ describe("MegolmDecryption", function () {
//@ts-ignore private member access, gross
await megolm.encryptionPreparation?.promise;
for (const userId in deviceMap) {
for (const deviceId in deviceMap[userId]) {
for (const [userId, devices] of deviceMap) {
for (const deviceId of devices.keys()) {
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
}
}
@@ -658,20 +672,20 @@ describe("MegolmDecryption", function () {
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice1["org.matrix.msgid"];
delete contentMap["@bob:example.com"].bobdevice2.session_id;
delete contentMap["@bob:example.com"].bobdevice2["org.matrix.msgid"];
expect(contentMap).toStrictEqual({
"@bob:example.com": {
bobdevice1: {
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["session_id"];
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["org.matrix.msgid"];
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["session_id"];
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["org.matrix.msgid"];
expect(recursiveMapToObject(contentMap)).toStrictEqual({
["@bob:example.com"]: {
["bobdevice1"]: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: "m.unverified",
reason: "The sender has disabled encrypting to unverified devices.",
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
["bobdevice2"]: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: "m.blacklisted",
@@ -839,10 +853,10 @@ describe("MegolmDecryption", function () {
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"]["bobdevice"]["org.matrix.msgid"];
expect(contentMap).toStrictEqual({
"@bob:example.com": {
bobdevice: {
delete contentMap.get("@bob:example.com")?.get("bobdevice")?.["org.matrix.msgid"];
expect(recursiveMapToObject(contentMap)).toStrictEqual({
["@bob:example.com"]: {
["bobdevice"]: {
algorithm: "m.megolm.v1.aes-sha2",
code: "m.no_olm",
reason: "Unable to establish a secure channel.",
+15 -16
View File
@@ -146,18 +146,21 @@ describe("OlmDevice", function () {
});
},
} as unknown as MockedObject<MatrixClient>;
const devicesByUser = {
"@bob:example.com": [
DeviceInfo.fromStorage(
{
keys: {
"curve25519:ABCDEFG": "akey",
const devicesByUser = new Map([
[
"@bob:example.com",
[
DeviceInfo.fromStorage(
{
keys: {
"curve25519:ABCDEFG": "akey",
},
},
},
"ABCDEFG",
),
"ABCDEFG",
),
],
],
};
]);
// start two tasks that try to ensure that there's an olm session
const promises = Promise.all([
@@ -218,12 +221,8 @@ describe("OlmDevice", function () {
// There's no required ordering of devices per user, so here we
// create two different orderings so that each task reserves a
// device the other task needs before continuing.
const devicesByUserAB = {
"@bob:example.com": [deviceBobA, deviceBobB],
};
const devicesByUserBA = {
"@bob:example.com": [deviceBobB, deviceBobA],
};
const devicesByUserAB = new Map([["@bob:example.com", [deviceBobA, deviceBobB]]]);
const devicesByUserBA = new Map([["@bob:example.com", [deviceBobB, deviceBobA]]]);
const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB));
+2
View File
@@ -456,6 +456,7 @@ describe("MegolmBackup", function () {
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
/* eslint-disable jest/no-conditional-expect */
if (numCalls === 1) {
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
@@ -482,6 +483,7 @@ describe("MegolmBackup", function () {
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
}
/* eslint-enable jest/no-conditional-expect */
};
}),
client.createKeyBackupVersion({
+6 -5
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";
@@ -25,10 +24,10 @@ import { encryptAES } from "../../../src/crypto/aes";
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
import { logger } from "../../../src/logger";
import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client";
import { ISecretStorageKeyInfo } from "../../../src/crypto/api";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { ISignatures } from "../../../src/@types/signed";
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
@@ -45,7 +44,7 @@ async function makeTestClient(
await client.initCrypto();
// No need to download keys for these tests
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue({});
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue(new Map());
return client;
}
@@ -274,7 +273,7 @@ describe("Secrets", function () {
Object.values(otks)[0],
);
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
osborne2.client.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const request = await secretStorage.request("foo", ["VAX"]);
@@ -541,7 +540,9 @@ describe("Secrets", function () {
await alice.bootstrapSecretStorage({});
expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" });
const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")!.getContent<ISecretStorageKeyInfo>();
const keyInfo = alice
.getAccountData("m.secret_storage.key.key_id")!
.getContent<SecretStorageKeyDescription>();
expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2");
expect(keyInfo.passphrase).toEqual({
algorithm: "m.pbkdf2",
+22 -11
View File
@@ -121,12 +121,12 @@ describe("SAS verification", function () {
alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES);
alice.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES);
bob.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
aliceSasEvent = null;
@@ -176,6 +176,7 @@ describe("SAS verification", function () {
}
});
});
afterEach(async () => {
await Promise.all([alice.stop(), bob.stop()]);
@@ -186,10 +187,14 @@ describe("SAS verification", function () {
let macMethod;
let keyAgreement;
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = function (type, map) {
bob.client.sendToDevice = async (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
keyAgreement = map[alice.client.getUserId()!][alice.client.deviceId!].key_agreement_protocol;
macMethod = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.message_authentication_code;
keyAgreement = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.key_agreement_protocol;
}
return origSendToDevice(type, map);
};
@@ -237,7 +242,7 @@ describe("SAS verification", function () {
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = [
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hkdf-hmac-sha256",
];
}
@@ -246,7 +251,9 @@ describe("SAS verification", function () {
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
@@ -291,14 +298,18 @@ describe("SAS verification", function () {
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = ["hmac-sha256"];
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hmac-sha256",
];
}
return aliceOrigSendToDevice(type, map);
};
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
@@ -454,7 +465,7 @@ describe("SAS verification", function () {
);
};
alice.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
bob.client.crypto!.setDeviceVerification = jest.fn();
@@ -472,7 +483,7 @@ describe("SAS verification", function () {
return "bob+base64+ed25519+key";
};
bob.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
aliceSasEvent = null;
+5 -5
View File
@@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
import { Room, RoomEvent } from "../../../../src/models/room";
import { logger } from "../../../../src/logger";
import { MatrixClient, ClientEvent, ICreateClientOpts } from "../../../../src/client";
import { MatrixClient, ClientEvent, ICreateClientOpts, SendToDeviceContentMap } from "../../../../src/client";
interface UserInfo {
userId: string;
@@ -36,16 +36,16 @@ export async function makeTestClients(
const clientMap: Record<string, Record<string, MatrixClient>> = {};
const makeSendToDevice =
(matrixClient: MatrixClient): MatrixClient["sendToDevice"] =>
async (type, map) => {
async (type: string, contentMap: SendToDeviceContentMap) => {
// logger.log(this.getUserId(), "sends", type, map);
for (const [userId, devMap] of Object.entries(map)) {
for (const [userId, deviceMessages] of contentMap) {
if (userId in clientMap) {
for (const [deviceId, msg] of Object.entries(devMap)) {
for (const [deviceId, message] of deviceMessages) {
if (deviceId in clientMap[userId]) {
const event = new MatrixEvent({
sender: matrixClient.getUserId()!,
type: type,
content: msg,
content: message,
});
const client = clientMap[userId][deviceId];
const decryptionPromise = event.isEncrypted()
@@ -25,6 +25,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
import { MatrixClient } from "../../../../src/client";
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
import { MapWithDefault } from "../../../../src/utils";
type MockClient = MatrixClient & {
popEvents: () => MatrixEvent[];
@@ -33,7 +34,9 @@ type MockClient = MatrixClient & {
function makeMockClient(userId: string, deviceId: string): MockClient {
let counter = 1;
let events: MatrixEvent[] = [];
const deviceEvents: Record<string, Record<string, MatrixEvent[]>> = {};
const deviceEvents: MapWithDefault<string, MapWithDefault<string, MatrixEvent[]>> = new MapWithDefault(
() => new MapWithDefault(() => []),
);
return {
getUserId() {
return userId;
@@ -58,15 +61,11 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
return Promise.resolve({ event_id: eventId });
},
sendToDevice(type: string, msgMap: Record<string, Record<string, IContent>>) {
for (const userId of Object.keys(msgMap)) {
const deviceMap = msgMap[userId];
for (const deviceId of Object.keys(deviceMap)) {
const content = deviceMap[deviceId];
sendToDevice(type: string, msgMap: Map<string, Map<string, IContent>>) {
for (const [userId, deviceMessages] of msgMap) {
for (const [deviceId, content] of deviceMessages) {
const event = new MatrixEvent({ content, type });
deviceEvents[userId] = deviceEvents[userId] || {};
deviceEvents[userId][deviceId] = deviceEvents[userId][deviceId] || [];
deviceEvents[userId][deviceId].push(event);
deviceEvents.getOrCreate(userId).getOrCreate(deviceId).push(event);
}
}
return Promise.resolve({});
@@ -79,14 +78,9 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
return e;
},
// @ts-ignore special testing fn
popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] {
const forDevice = deviceEvents[userId];
const events = forDevice && forDevice[deviceId];
const result = events || [];
if (events) {
delete forDevice[deviceId];
}
const result = deviceEvents.get(userId)?.get(deviceId) || [];
deviceEvents?.get(userId)?.delete(deviceId);
return result;
},
} as unknown as MockClient;
+10 -5
View File
@@ -204,9 +204,14 @@ describe("RoomWidgetClient", () => {
});
describe("to-device messages", () => {
const unencryptedContentMap = {
"@alice:example.org": { "*": { hello: "alice!" } },
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
const unencryptedContentMap = new Map([
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
["@bob:example.org", new Map([["bobDesktop", { hello: "bob!" }]])],
]);
const expectedRequestData = {
["@alice:example.org"]: { ["*"]: { hello: "alice!" } },
["@bob:example.org"]: { ["bobDesktop"]: { hello: "bob!" } },
};
it("sends unencrypted (sendToDevice)", async () => {
@@ -214,7 +219,7 @@ describe("RoomWidgetClient", () => {
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
await client.sendToDevice("org.example.foo", unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
});
it("sends unencrypted (queueToDevice)", async () => {
@@ -229,7 +234,7 @@ describe("RoomWidgetClient", () => {
],
};
await client.queueToDevice(batch);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
});
it("sends encrypted (encryptAndSendToDevices)", async () => {
-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,
+219 -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,7 @@ 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";
jest.useFakeTimers();
@@ -725,6 +732,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -743,6 +751,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -785,6 +794,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -803,6 +813,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -820,6 +831,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -837,6 +849,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -858,6 +871,7 @@ describe("MatrixClient", function () {
const syncPromise = new Promise<void>((resolve, reject) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "SYNCING") {
// eslint-disable-next-line jest/no-conditional-expect
expect(httpLookups.length).toEqual(0);
client.removeListener(ClientEvent.Sync, syncListener);
resolve();
@@ -944,6 +958,7 @@ describe("MatrixClient", function () {
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
/* eslint-disable jest/no-conditional-expect */
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(2);
expect(client.retryImmediately()).toBe(true);
@@ -955,6 +970,7 @@ describe("MatrixClient", function () {
// unexpected state transition!
expect(state).toEqual(null);
}
/* eslint-enable jest/no-conditional-expect */
});
});
await client.startClient();
@@ -976,8 +992,10 @@ describe("MatrixClient", function () {
const isSyncingPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
/* eslint-disable jest/no-conditional-expect */
expect(httpLookups.length).toEqual(1);
expect(client.retryImmediately()).toBe(true);
/* eslint-enable jest/no-conditional-expect */
jest.advanceTimersByTime(1);
} else if (state === "RECONNECTING" && httpLookups.length > 0) {
jest.advanceTimersByTime(10000);
@@ -1004,6 +1022,7 @@ describe("MatrixClient", function () {
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
/* eslint-disable jest/no-conditional-expect */
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(3);
expect(client.retryImmediately()).toBe(true);
@@ -1015,6 +1034,7 @@ describe("MatrixClient", function () {
// unexpected state transition!
expect(state).toEqual(null);
}
/* eslint-enable jest/no-conditional-expect */
});
});
await client.startClient();
@@ -2691,4 +2711,202 @@ 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");
});
});
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,
},
});
});
});
});
});
+2
View File
@@ -265,11 +265,13 @@ describe("MSC3089Branch", () => {
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
expect(stateKey).toEqual(stateKeyOrder[stateFn.mock.calls.length - 1]);
if (stateKey === fileEventId) {
// eslint-disable-next-line jest/no-conditional-expect
expect(content).toMatchObject({
retained: true, // canary for copying state
active: false,
});
} else if (stateKey === fileEventId2) {
// eslint-disable-next-line jest/no-conditional-expect
expect(content).toMatchObject({
active: true,
version: 2,
+12 -20
View File
@@ -130,14 +130,8 @@ describe("MSC3089TreeSpace", () => {
return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" }));
});
client.invite = fn;
try {
await tree.invite(target, false, false);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<MatrixError>e).errcode).toEqual("M_FORBIDDEN");
}
await expect(tree.invite(target, false, false)).rejects.toThrow("MatrixError: Sample Failure");
expect(fn).toHaveBeenCalledTimes(1);
});
@@ -357,13 +351,18 @@ describe("MSC3089TreeSpace", () => {
.fn()
.mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect([tree.roomId, subspaceId]).toContain(roomId);
let expectedType: string;
let expectedStateKey: string;
if (roomId === subspaceId) {
expect(eventType).toEqual(EventType.SpaceParent);
expect(stateKey).toEqual(tree.roomId);
expectedType = EventType.SpaceParent;
expectedStateKey = tree.roomId;
} else {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toEqual(subspaceId);
expectedType = EventType.SpaceChild;
expectedStateKey = subspaceId;
}
expect(eventType).toEqual(expectedType);
expect(stateKey).toEqual(expectedStateKey);
expect(content).toMatchObject({ via: [domain] });
// return value not used
@@ -629,15 +628,8 @@ describe("MSC3089TreeSpace", () => {
});
it("should throw when setting an order at the top level space", async () => {
try {
// The tree is what we've defined as top level, so it should work
await tree.setOrder(2);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toEqual("Cannot set order of top level spaces currently");
}
// The tree is what we've defined as top level, so it should work
await expect(tree.setOrder(2)).rejects.toThrow("Cannot set order of top level spaces currently");
});
it("should return a stable order for unordered children", () => {
+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);
},
);
});
});
+105 -9
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 } from "../../src";
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,8 @@ describe("NotificationService", function () {
},
};
},
credentials: {
userId: testUserId,
},
...mockClientMethodsUser(testUserId),
supportsIntentionalMentions: () => true,
pushRules: {
device: {},
global: {
@@ -572,11 +572,76 @@ describe("NotificationService", function () {
});
const actions = pushProcessor.actionsForEvent(testEvent);
if (expected) {
expect(actions?.notify).toBeTruthy();
} else {
expect(actions?.notify).toBeFalsy();
}
expect(!!actions?.notify).toBe(expected);
});
});
describe("Test event property contains", () => {
it.each([
// Simple string matching.
{ value: "bar", eventValue: ["bar"], expected: true },
// Matches are case-sensitive.
{ value: "bar", eventValue: ["BAR"], expected: false },
// Values should not be type-coerced.
{ value: "bar", eventValue: [true], expected: false },
{ value: "bar", eventValue: [1], expected: false },
{ value: "bar", eventValue: [false], expected: false },
// Boolean matching.
{ value: true, eventValue: [true], expected: true },
{ value: false, eventValue: [false], expected: true },
// Types should not be coerced.
{ value: true, eventValue: ["true"], expected: false },
{ value: true, eventValue: [1], expected: false },
{ value: false, eventValue: [null], expected: false },
// Null matching.
{ value: null, eventValue: [null], expected: true },
// Types should not be coerced
{ value: null, eventValue: [false], expected: false },
{ value: null, eventValue: [0], expected: false },
{ value: null, eventValue: [""], expected: false },
{ value: null, eventValue: [undefined], expected: false },
// Non-array or empty values should never be matched.
{ value: "bar", eventValue: "bar", expected: false },
{ value: "bar", eventValue: { bar: true }, expected: false },
{ value: true, eventValue: { true: true }, expected: false },
{ value: true, eventValue: true, expected: false },
{ value: null, eventValue: [], expected: false },
{ value: null, eventValue: {}, expected: false },
{ value: null, eventValue: null, expected: false },
{ value: null, eventValue: undefined, expected: false },
])("test $value against $eventValue", ({ value, eventValue, expected }) => {
matrixClient.pushRules! = {
global: {
override: [
{
actions: [PushRuleActionName.Notify],
conditions: [
{
kind: ConditionKind.EventPropertyContains,
key: "content.foo",
value: value,
},
],
default: true,
enabled: true,
rule_id: ".m.rule.test",
},
],
},
};
testEvent = utils.mkEvent({
type: "m.room.message",
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {
foo: eventValue,
},
});
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions?.notify).toBe(expected ? true : undefined);
});
});
@@ -647,6 +712,37 @@ describe("NotificationService", function () {
});
});
});
describe("test intentional mentions behaviour", () => {
it.each([RuleId.ContainsUserName, RuleId.ContainsDisplayName, RuleId.AtRoomNotification])(
"Rule %s matches unless intentional mentions are enabled",
(ruleId) => {
const rule = {
rule_id: ruleId,
actions: [],
conditions: [],
default: false,
enabled: true,
};
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(true);
// Add the mentions property to the event and the rule is now disabled.
testEvent = utils.mkEvent({
type: "m.room.message",
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {
"body": "",
"msgtype": "m.text",
"org.matrix.msc3952.mentions": {},
},
});
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(false);
},
);
});
});
describe("Test PushProcessor.partsForDottedKey", function () {
+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",
+23 -18
View File
@@ -25,6 +25,8 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
import { DecryptionError } from "../../src/crypto/algorithms";
import { defer } from "../../src/utils";
describe("RoomState", function () {
const roomId = "!foo:bar";
@@ -886,7 +888,7 @@ describe("RoomState", function () {
expect(emitSpy).not.toHaveBeenCalled();
});
it("adds locations to beacons", () => {
it("adds locations to beacons", async () => {
const location1 = makeBeaconEvent(userA, {
beaconInfoId: "$beacon1",
timestamp: Date.now() + 1,
@@ -906,7 +908,7 @@ describe("RoomState", function () {
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations");
state.processBeaconEvents([location1, location2, location3], mockClient);
await state.processBeaconEvents([location1, location2, location3], mockClient);
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
// only called with locations for beacon1
@@ -978,51 +980,50 @@ describe("RoomState", function () {
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it("decrypts related events if needed", () => {
it("decrypts related events if needed", async () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: beacon1.getId(),
});
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
await state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
});
it("listens for decryption on events that are being decrypted", () => {
it("awaits for decryption on events that are being decrypted", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalled();
});
it("listens for decryption on events that have decryption failure", () => {
it("listens for decryption on events that have decryption failure", async () => {
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([failedDecryptionRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it("discard events that are not m.beacon type after decryption", () => {
it("discard events that are not m.beacon type after decryption", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
@@ -1032,7 +1033,7 @@ describe("RoomState", function () {
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption
decryptingRelatedEvent.event.type = EventType.RoomMessage;
@@ -1041,7 +1042,7 @@ describe("RoomState", function () {
expect(addLocationsSpy).not.toHaveBeenCalled();
});
it("adds locations to beacons after decryption", () => {
it("adds locations to beacons after decryption", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
@@ -1051,16 +1052,20 @@ describe("RoomState", function () {
beaconInfoId: "$beacon1",
timestamp: Date.now() + 1,
});
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
const deferred = defer<void>();
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
const prom = state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.event.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
deferred.resolve();
await prom;
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});
+71 -27
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, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise } from "../test-utils/test-utils";
@@ -53,6 +53,7 @@ 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";
describe("Room", function () {
const roomId = "!foo:bar";
@@ -171,6 +172,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", () => {
@@ -390,36 +393,30 @@ describe("Room", function () {
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
const remoteEventId = remoteEvent.getId();
let callCount = 0;
room.on(RoomEvent.LocalEchoUpdated, (event, emitRoom, oldEventId, oldStatus) => {
switch (callCount) {
case 0:
expect(event.getId()).toEqual(localEventId);
expect(event.status).toEqual(EventStatus.SENDING);
expect(emitRoom).toEqual(room);
expect(oldEventId).toBeUndefined();
expect(oldStatus).toBeUndefined();
break;
case 1:
expect(event.getId()).toEqual(remoteEventId);
expect(event.status).toBeNull();
expect(emitRoom).toEqual(room);
expect(oldEventId).toEqual(localEventId);
expect(oldStatus).toBe(EventStatus.SENDING);
break;
}
callCount += 1;
});
const stub = jest.fn();
room.on(RoomEvent.LocalEchoUpdated, stub);
// first add the local echo
room.addPendingEvent(localEvent, "TXN_ID");
expect(room.timeline.length).toEqual(1);
expect(stub.mock.calls[0][0].getId()).toEqual(localEventId);
expect(stub.mock.calls[0][0].status).toEqual(EventStatus.SENDING);
expect(stub.mock.calls[0][1]).toEqual(room);
expect(stub.mock.calls[0][2]).toBeUndefined();
expect(stub.mock.calls[0][3]).toBeUndefined();
// then the remoteEvent
room.addLiveEvents([remoteEvent]);
expect(room.timeline.length).toEqual(1);
expect(callCount).toEqual(2);
expect(stub).toHaveBeenCalledTimes(2);
expect(stub.mock.calls[1][0].getId()).toEqual(remoteEventId);
expect(stub.mock.calls[1][0].status).toBeNull();
expect(stub.mock.calls[1][1]).toEqual(room);
expect(stub.mock.calls[1][2]).toEqual(localEventId);
expect(stub.mock.calls[1][3]).toBe(EventStatus.SENDING);
});
it("should be able to update local echo without a txn ID (/send then /sync)", function () {
@@ -3267,7 +3264,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];
@@ -3280,6 +3277,7 @@ describe("Room", function () {
},
},
});
const messageEvent = new MatrixEvent({
type: "m.room.messsage",
content: {
@@ -3287,6 +3285,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);
@@ -3295,7 +3306,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);
@@ -3303,6 +3314,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);
});
});
@@ -3349,11 +3386,16 @@ describe("Room", function () {
newRoomId: string,
predecessorRoomId: string,
tombstoneEventId: string | null = null,
viaServers: string[] = [],
): MatrixEvent {
const content =
tombstoneEventId === null
? { predecessor_room_id: predecessorRoomId }
: { predecessor_room_id: predecessorRoomId, last_known_event_id: tombstoneEventId };
? { predecessor_room_id: predecessorRoomId, via_servers: viaServers }
: {
predecessor_room_id: predecessorRoomId,
last_known_event_id: tombstoneEventId,
via_servers: viaServers,
};
return new MatrixEvent({
content,
@@ -3393,6 +3435,7 @@ describe("Room", function () {
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: undefined, // m.predecessor did not include an event_id
viaServers: [],
});
});
@@ -3400,12 +3443,13 @@ describe("Room", function () {
const room = new Room("roomid", client!, "@u:example.com");
room.addLiveEvents([
roomCreateEvent("roomid", "replacedroomid"),
predecessorEvent("roomid", "otherreplacedroomid", "lstevtid"),
predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", ["one.example.com", "two.example.com"]),
]);
const useMsc3946 = true;
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: "lstevtid",
viaServers: ["one.example.com", "two.example.com"],
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ describe("RustCrypto", () => {
});
});
describe("to-device messages", () => {
describe("call preprocess methods", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
+5
View File
@@ -58,10 +58,12 @@ describe("MatrixScheduler", function () {
let yieldedA = false;
scheduler.setProcessFunction(function (event) {
if (yieldedA) {
// eslint-disable-next-line jest/no-conditional-expect
expect(event).toEqual(eventB);
return deferB.promise;
} else {
yieldedA = true;
// eslint-disable-next-line jest/no-conditional-expect
expect(event).toEqual(eventA);
return deferA.promise;
}
@@ -89,6 +91,7 @@ describe("MatrixScheduler", function () {
scheduler.setProcessFunction(function (ev) {
procCount += 1;
if (procCount === 1) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
@@ -129,9 +132,11 @@ describe("MatrixScheduler", function () {
scheduler.setProcessFunction(function (ev) {
procCount += 1;
if (procCount === 1) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventB);
return deferB.promise;
}
+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>;
}
@@ -0,0 +1,47 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
import { defer } from "../../../src/utils";
function setupWorker(worker: IndexedDBStoreWorker): void {
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
worker.onMessage({ data: { command: "connect", seq: 1 } } as any);
}
describe("IndexedDBStore Worker", () => {
it("should pass 'closed' event via postMessage", async () => {
const deferred = defer<void>();
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
if (seq === 1 && command === "cmd_success") {
deferred.resolve();
}
});
const worker = new IndexedDBStoreWorker(postMessage);
setupWorker(worker);
await deferred.promise;
// @ts-ignore - private field access
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
expect(postMessage).toHaveBeenCalledWith({
command: "closed",
});
});
});
+88
View File
@@ -166,4 +166,92 @@ describe("IndexedDBStore", () => {
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should emit 'closed' if database is unexpectedly closed", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
// @ts-ignore - private field access
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
await deferred.promise;
});
it("should use remote backend if workerFactory passed", async () => {
const deferred = defer<void>();
class MockWorker {
postMessage(data: any) {
if (data.command === "setupWorker") {
deferred.resolve();
}
}
}
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => new MockWorker() as Worker,
});
store.startup();
await deferred.promise;
});
it("remote worker should pass closed event", async () => {
const worker = new (class MockWorker {
postMessage(data: any) {}
})() as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
(worker as any).onmessage({ data: { command: "closed" } });
await deferred.promise;
});
it("remote worker should pass command failures", async () => {
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
if (data.command === "setupWorker" || data.command === "connect") {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
},
});
return;
}
this.onmessage({
data: {
command: "cmd_fail",
seq: data.seq,
error: new Error("Test"),
},
});
}
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await expect(store.startup()).rejects.toThrow("Test");
});
});
+1 -1
View File
@@ -59,7 +59,7 @@ describe("MemoryStore", () => {
await store.deleteAllData();
// empty object
expect(store.accountData).toEqual({});
expect(store.accountData).toEqual(new Map());
});
});
});
+102
View File
@@ -24,9 +24,12 @@ import {
lexicographicCompare,
nextString,
prevString,
recursiveMapToObject,
simpleRetryOperation,
stringToBase,
sortEventsByLatestContentTimestamp,
safeSet,
MapWithDefault,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
@@ -606,6 +609,105 @@ describe("utils", function () {
});
});
describe("recursiveMapToObject", () => {
it.each([
// empty map
{
map: new Map(),
expected: {},
},
// one level map
{
map: new Map<any, any>([
["key1", "value 1"],
["key2", 23],
["key3", undefined],
["key4", null],
["key5", [1, 2, 3]],
]),
expected: { key1: "value 1", key2: 23, key3: undefined, key4: null, key5: [1, 2, 3] },
},
// two level map
{
map: new Map<any, any>([
[
"key1",
new Map<any, any>([
["key1_1", "value 1"],
["key1_2", "value 1.2"],
]),
],
["key2", "value 2"],
]),
expected: { key1: { key1_1: "value 1", key1_2: "value 1.2" }, key2: "value 2" },
},
// multi level map
{
map: new Map<any, any>([
["key1", new Map<any, any>([["key1_1", new Map<any, any>([["key1_1_1", "value 1.1.1"]])]])],
]),
expected: { key1: { key1_1: { key1_1_1: "value 1.1.1" } } },
},
// list of maps
{
map: new Map<any, any>([
[
"key1",
[new Map<any, any>([["key1_1", "value 1.1"]]), new Map<any, any>([["key1_2", "value 1.2"]])],
],
]),
expected: { key1: [{ key1_1: "value 1.1" }, { key1_2: "value 1.2" }] },
},
// map → array → array → map
{
map: new Map<any, any>([["key1", [[new Map<any, any>([["key2", "value 2"]])]]]]),
expected: {
key1: [
[
{
key2: "value 2",
},
],
],
},
},
])("%# should convert the value", ({ map, expected }) => {
expect(recursiveMapToObject(map)).toStrictEqual(expected);
});
});
describe("safeSet", () => {
it("should set a value", () => {
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(<Record<string, string>>{}, prop, "teset value");
}).toThrow("Trying to modify prototype or constructor");
});
});
describe("MapWithDefault", () => {
it("getOrCreate should create the value if it does not exist", () => {
const newValue = {};
const map = new MapWithDefault(() => newValue);
// undefined before getOrCreate
expect(map.get("test")).toBeUndefined();
expect(map.getOrCreate("test")).toBe(newValue);
// default value after getOrCreate
expect(map.get("test")).toBe(newValue);
// test that it always returns the same value
expect(map.getOrCreate("test")).toBe(newValue);
});
});
describe("sleep", () => {
it("resolves", async () => {
await utils.sleep(0);
+85 -2
View File
@@ -431,6 +431,58 @@ describe("Call", function () {
expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track");
});
it("should handle error on call upgrade", async () => {
const onError = jest.fn();
call.on(CallEvent.Error, onError);
await startVoiceCall(client, call);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
const mockGetUserMediaStream = jest.fn().mockRejectedValue(new Error("Test error"));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
expect(onError).toHaveBeenCalled();
});
it("should unmute video after upgrading to video call", async () => {
// Regression test for https://github.com/vector-im/element-call/issues/925
await startVoiceCall(client, call);
// start off with video muted
await call.setLocalVideoMuted(true);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
// video should now be unmuted
expect(call.isLocalVideoMuted()).toBe(false);
});
it("should handle SDPStreamMetadata changes", async () => {
await startVoiceCall(client, call);
@@ -712,11 +764,22 @@ describe("Call", function () {
const dataChannel = call.createDataChannel("data_channel_label", { id: 123 });
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel);
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel, call);
expect(dataChannel.label).toBe("data_channel_label");
expect(dataChannel.id).toBe(123);
});
it("should emit a data channel event when the other side adds a data channel", async () => {
await startVoiceCall(client, call);
const dataChannelCallback = jest.fn();
call.on(CallEvent.DataChannel, dataChannelCallback);
(call.peerConn as unknown as MockRTCPeerConnection).triggerIncomingDataChannel();
expect(dataChannelCallback).toHaveBeenCalled();
});
describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true);
@@ -1579,7 +1642,7 @@ describe("Call", function () {
hasAdvancedBy += advanceBy;
expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy, call);
}
});
@@ -1609,4 +1672,24 @@ describe("Call", function () {
expect(call.hangup).not.toHaveBeenCalled();
});
});
describe("Call replace", () => {
it("Fires event when call replaced", async () => {
const onReplace = jest.fn();
call.on(CallEvent.Replaced, onReplace);
await call.placeVoiceCall();
const call2 = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
call2.on(CallEvent.Error, errorListener);
await fakeIncomingCall(client, call2);
call.replacedBy(call2);
expect(onReplace).toHaveBeenCalled();
});
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe("CallFeed", () => {
[CallState.Connected, true],
[CallState.Connecting, false],
])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => {
call.emit(CallEvent.State, state);
call.emit(CallEvent.State, state, CallState.InviteSent, call.typed());
expect(feed.connected).toBe(expected);
});
+102 -24
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
@@ -95,16 +97,16 @@ const FAKE_STATE_EVENTS = [
},
];
const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => {
if (type === EventType.GroupCallMemberPrefix) {
return userId === undefined
? (FAKE_STATE_EVENTS as MatrixEvent[])
: (FAKE_STATE_EVENTS.find((e) => e.getStateKey() === userId) as MatrixEvent);
} else {
const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent;
return userId === undefined ? [fakeEvent] : fakeEvent;
}
};
const mockGetStateEvents =
(events: MatrixEvent[] = FAKE_STATE_EVENTS as MatrixEvent[]) =>
(type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => {
if (type === EventType.GroupCallMemberPrefix) {
return userId === undefined ? events : events.find((e) => e.getStateKey() === userId) ?? null;
} else {
const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent;
return userId === undefined ? [fakeEvent] : fakeEvent;
}
};
const ONE_HOUR = 1000 * 60 * 60;
@@ -142,6 +144,10 @@ describe("Group Call", function () {
} as unknown as RoomMember;
});
afterEach(() => {
groupCall.leave();
});
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
"throws when initializing local call feed in %s state",
async (state: GroupCallState) => {
@@ -567,7 +573,7 @@ describe("Group Call", function () {
// the call starts muted, so unmute to get in the right state to test
await groupCall.setMicrophoneMuted(false);
mockCall.localUsermediaFeed.setAudioVideoMuted.mockReset();
mocked(mockCall.localUsermediaFeed.setAudioVideoMuted).mockReset();
let metadataUpdateResolve: () => void;
const metadataUpdatePromise = new Promise<void>((resolve) => {
@@ -688,15 +694,15 @@ describe("Group Call", function () {
expect(client1.sendToDevice.mock.calls[0][0]).toBe("m.call.invite");
const toDeviceCallContent = client1.sendToDevice.mock.calls[0][1];
expect(Object.keys(toDeviceCallContent).length).toBe(1);
expect(Object.keys(toDeviceCallContent)[0]).toBe(FAKE_USER_ID_2);
expect(toDeviceCallContent.size).toBe(1);
expect(toDeviceCallContent.has(FAKE_USER_ID_2)).toBe(true);
const toDeviceBobDevices = toDeviceCallContent[FAKE_USER_ID_2];
expect(Object.keys(toDeviceBobDevices).length).toBe(1);
expect(Object.keys(toDeviceBobDevices)[0]).toBe(FAKE_DEVICE_ID_2);
const toDeviceBobDevices = toDeviceCallContent.get(FAKE_USER_ID_2);
expect(toDeviceBobDevices?.size).toBe(1);
expect(toDeviceBobDevices?.has(FAKE_DEVICE_ID_2)).toBe(true);
const bobDeviceMessage = toDeviceBobDevices[FAKE_DEVICE_ID_2];
expect(bobDeviceMessage.conf_id).toBe(FAKE_CONF_ID);
const bobDeviceMessage = toDeviceBobDevices?.get(FAKE_DEVICE_ID_2);
expect(bobDeviceMessage?.conf_id).toBe(FAKE_CONF_ID);
} finally {
await Promise.all([groupCall1.leave(), groupCall2.leave()]);
}
@@ -792,7 +798,7 @@ describe("Group Call", function () {
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
call.setLocalVideoMuted = jest.fn();
call.emit(CallEvent.State, CallState.Connected);
call.emit(CallEvent.State, CallState.Connected, CallState.InviteSent, call);
expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false);
expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false);
@@ -811,7 +817,7 @@ describe("Group Call", function () {
mockClient = typedMockClient as unknown as MatrixClient;
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
@@ -987,7 +993,14 @@ describe("Group Call", function () {
mockClient = typedMockClient as unknown as MatrixClient;
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
} as unknown as RoomMember;
room.currentState.members[FAKE_USER_ID_2] = {
userId: FAKE_USER_ID_2,
membership: "join",
} as unknown as RoomMember;
groupCall = await createAndEnterGroupCall(mockClient, room);
});
@@ -1060,6 +1073,71 @@ describe("Group Call", function () {
expect(call.answerWithCallFeeds).toHaveBeenCalled();
});
const aliceEnters = () => {
room.currentState.getStateEvents = jest.fn().mockImplementation(
mockGetStateEvents([
{
getContent: () => ({
"m.calls": [
{
"m.call_id": groupCall.groupCallId,
"m.devices": [
{
device_id: FAKE_DEVICE_ID_1,
session_id: FAKE_SESSION_ID_1,
expires_ts: Date.now() + ONE_HOUR,
feeds: [],
},
],
},
],
}),
getStateKey: () => FAKE_USER_ID_1,
getRoomId: () => FAKE_ROOM_ID,
getTs: () => 0,
},
] as unknown as MatrixEvent[]),
);
room.currentState.emit(RoomStateEvent.Update, room.currentState);
};
const aliceLeaves = () => {
room.currentState.getStateEvents = jest
.fn()
.mockImplementation(mockGetStateEvents([] as unknown as MatrixEvent[]));
room.currentState.emit(RoomStateEvent.Update, room.currentState);
};
it("enables tracks on expected calls, then disables them when the participant leaves", async () => {
aliceEnters();
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
// Tracks should be enabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
aliceLeaves();
// Tracks should be disabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
});
it("disables tracks on unexpected calls, then enables them when the participant joins", async () => {
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
// Tracks should be disabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
aliceEnters();
// Tracks should be enabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
});
describe("handles call being replaced", () => {
let callChangedListener: jest.Mock;
let oldMockCall: MockMatrixCall;
@@ -1080,7 +1158,7 @@ describe("Group Call", function () {
});
it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
@@ -1091,7 +1169,7 @@ describe("Group Call", function () {
it("handles case where call is missing from the calls map", () => {
// @ts-ignore
groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
@@ -1157,7 +1235,7 @@ describe("Group Call", function () {
userId: FAKE_USER_ID_2,
membership: "join",
} as unknown as RoomMember;
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
groupCall = await createAndEnterGroupCall(mockClient, room);
});
+38 -2
View File
@@ -98,6 +98,23 @@ describe("Group Call Event Handler", function () {
expect(groupCall.state).toBe(GroupCallState.Ended);
});
it("terminates call when redacted", async () => {
await groupCallEventHandler.start();
mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), {
roomId: FAKE_ROOM_ID,
} as unknown as RoomState);
const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!;
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, undefined, true), {
roomId: FAKE_ROOM_ID,
} as unknown as RoomState);
expect(groupCall.state).toBe(GroupCallState.Ended);
});
});
it("waits until client starts syncing", async () => {
@@ -222,9 +239,9 @@ describe("Group Call Event Handler", function () {
jest.clearAllMocks();
});
const setupCallAndStart = async (content?: IContent) => {
const setupCallAndStart = async (content?: IContent, redacted?: boolean) => {
mocked(mockRoom.currentState.getStateEvents).mockReturnValue([
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content),
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content, redacted),
] as unknown as MatrixEvent);
mockClient.getRooms.mockReturnValue([mockRoom]);
await groupCallEventHandler.start();
@@ -285,5 +302,24 @@ describe("Group Call Event Handler", function () {
}),
);
});
it("ignores redacted calls", async () => {
await setupCallAndStart(
{
// Real event contents to make sure that it's specifically the
// event being redacted that causes it to be ignored
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
},
true,
);
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
});
});
@@ -0,0 +1,46 @@
/*
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 { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
describe("ConnectionStatsReporter", () => {
describe("should on bandwidth stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
describe("should on connection stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
});
@@ -0,0 +1,145 @@
/*
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 { 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";
const TIME_INTERVAL = 10000;
describe("GroupCallStats", () => {
let stats: GroupCallStats;
beforeEach(() => {
stats = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, TIME_INTERVAL);
});
describe("should on adding a stats collector", () => {
it("creating a new one if not existing.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
});
it("creating only one when trying add the same collector multiple times.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeFalsy();
// The User ID is not relevant! Because for stats the call is needed and the user id is for monitoring
expect(stats.addStatsReportGatherer("CALL_ID", "SOME_OTHER_USER_ID", mockRTCPeerConnection())).toBeFalsy();
});
});
describe("should on removing a stats collector", () => {
it("returning `true` if the collector exists", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.removeStatsReportGatherer("CALL_ID")).toBeTruthy();
});
it("returning false if the collector not exists", async () => {
expect(stats.removeStatsReportGatherer("CALL_ID_NOT_EXIST")).toBeFalsy();
});
});
describe("should on get stats collector", () => {
it("returning `undefined` if collector not existing", async () => {
expect(stats.getStatsReportGatherer("CALL_ID")).toBeUndefined();
});
it("returning Collector if collector existing", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.getStatsReportGatherer("CALL_ID")).toBeDefined();
});
});
describe("should on start", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("starting processing as well without stats collectors", async () => {
// @ts-ignore
stats.processStats = jest.fn();
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(stats.processStats).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").mockResolvedValue(summaryStats);
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
} else {
throw new Error("Test failed, because no Collector found!");
}
expect(processStatsSpy).toHaveBeenCalledWith(GROUP_CALL_ID, LOCAL_USER_ID);
});
it("doing nothing if process already running", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.start();
stats.start();
stats.start();
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
});
});
describe("should on stop", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("finish stats process if was started", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
jest.spyOn(global, "clearInterval");
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.stop();
expect(clearInterval).toHaveBeenCalledWith(22);
});
it("do nothing if stats process was not started", async () => {
jest.spyOn(global, "clearInterval");
stats.stop();
expect(clearInterval).not.toHaveBeenCalled();
});
});
});
const mockRTCPeerConnection = (): RTCPeerConnection => {
const pc = {} as RTCPeerConnection;
pc.addEventListener = jest.fn();
pc.getStats = jest.fn().mockResolvedValue(null);
return pc;
};
@@ -0,0 +1,41 @@
/*
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 { Mid, Ssrc, MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
import { REMOTE_SFU_DESCRIPTION } from "../../../../test-utils/webrtc";
describe("MediaSsrcHandler", () => {
const remoteMap = new Map<Mid, Ssrc[]>([
["0", ["2963372119"]],
["1", ["1212931603"]],
]);
let handler: MediaSsrcHandler;
beforeEach(() => {
handler = new MediaSsrcHandler();
});
describe("should parse description", () => {
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.", async () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0");
});
});
});
@@ -0,0 +1,127 @@
/*
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 { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
describe("TrackHandler", () => {
let pc: RTCPeerConnection;
let handler: MediaTrackHandler;
beforeEach(() => {
pc = {
getTransceivers: (): RTCRtpTransceiver[] => [mockTransceiver("1", "audio"), mockTransceiver("2", "video")],
} as RTCPeerConnection;
handler = new MediaTrackHandler(pc);
});
describe("should get local tracks", () => {
it("returns video track", async () => {
expect(handler.getLocalTracks("video")).toEqual([
{
id: `sender-track-2`,
kind: "video",
} as MediaStreamTrack,
]);
});
it("returns audio track", async () => {
expect(handler.getLocalTracks("audio")).toEqual([
{
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack,
]);
});
});
describe("should get local track by mid", () => {
it("returns video track", async () => {
expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2");
});
it("returns audio track", async () => {
expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1");
});
it("returns undefined if not exists", async () => {
expect(handler.getLocalTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get remote track by mid", () => {
it("returns video track", async () => {
expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2");
});
it("returns audio track", async () => {
expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1");
});
it("returns undefined if not exists", async () => {
expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get track by id", () => {
it("returns remote track", async () => {
expect(handler.getTackById("receiver-track-2")).toEqual({
id: `receiver-track-2`,
kind: "video",
} as MediaStreamTrack);
});
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", async () => {
expect(handler.getTackById("sender-track-3")).toBeUndefined();
});
});
describe("should get simulcast track count", () => {
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 => {
return {
mid,
currentDirection: "sendrecv",
sender: {
track: { id: `sender-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpSender,
receiver: {
track: { id: `receiver-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
};
@@ -0,0 +1,96 @@
/*
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 { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
import { MediaTrackStatsHandler } from "../../../../../src/webrtc/stats/media/mediaTrackStatsHandler";
import { MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
describe("MediaTrackStatsHandler", () => {
let statsHandler: MediaTrackStatsHandler;
let ssrcHandler: MediaSsrcHandler;
let trackHandler: MediaTrackHandler;
beforeEach(() => {
ssrcHandler = {} as MediaSsrcHandler;
trackHandler = {} as MediaTrackHandler;
trackHandler.getLocalTrackIdByMid = jest.fn().mockReturnValue("2222");
trackHandler.getRemoteTrackIdByMid = jest.fn().mockReturnValue("5555");
trackHandler.getLocalTracks = jest.fn().mockReturnValue([{ id: "2222" } as MediaStreamTrack]);
trackHandler.getTackById = jest.fn().mockReturnValue([{ id: "2222", kind: "audio" } as MediaStreamTrack]);
statsHandler = new MediaTrackStatsHandler(ssrcHandler, trackHandler);
});
describe("should find track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", () => {
const report = { trackIdentifier: "123" };
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("123");
});
it("and returns stats if `mid` exists in report", () => {
const reportIn = { mid: "1", type: "inbound-rtp" };
expect(statsHandler.findTrack2Stats(reportIn, "remote")?.trackId).toEqual("5555");
const reportOut = { mid: "1", type: "outbound-rtp" };
expect(statsHandler.findTrack2Stats(reportOut, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists in report but not on connection", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue(undefined);
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toBeUndefined();
});
it("and returns undefined if `ssrc` exists in inbound-rtp report", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("5555");
});
it("and returns undefined if `ssrc` exists in outbound-rtp report", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
describe("should find local video track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", async () => {
const report = { trackIdentifier: "2222" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns stats if `mid` exists in report", () => {
const report = { mid: "1" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
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);
});
});
});
@@ -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 { TrackID } from "../../../../src/webrtc/stats/statsReport";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
describe("StatsReportBuilder", () => {
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
const LOCAL_AUDIO_TRACK_ID = "LOCAL_AUDIO_TRACK_ID";
const REMOTE_AUDIO_TRACK_ID = "REMOTE_AUDIO_TRACK_ID";
const REMOTE_VIDEO_TRACK_ID = "REMOTE_VIDEO_TRACK_ID";
const localAudioTrack = new MediaTrackStats(LOCAL_AUDIO_TRACK_ID, "local", "audio");
const localVideoTrack = new MediaTrackStats(LOCAL_VIDEO_TRACK_ID, "local", "video");
const remoteAudioTrack = new MediaTrackStats(REMOTE_AUDIO_TRACK_ID, "remote", "audio");
const remoteVideoTrack = new MediaTrackStats(REMOTE_VIDEO_TRACK_ID, "remote", "video");
const stats = new Map<TrackID, MediaTrackStats>([
[LOCAL_AUDIO_TRACK_ID, localAudioTrack],
[LOCAL_VIDEO_TRACK_ID, localVideoTrack],
[REMOTE_AUDIO_TRACK_ID, remoteAudioTrack],
[REMOTE_VIDEO_TRACK_ID, remoteVideoTrack],
]);
beforeEach(() => {
buildData();
});
describe("should build stats", () => {
it("by media track stats.", async () => {
expect(StatsReportBuilder.build(stats)).toEqual({
bitrate: {
audio: {
download: 4000,
upload: 5000,
},
download: 5004000,
upload: 3005000,
video: {
download: 5000000,
upload: 3000000,
},
},
codec: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", "opus"],
["LOCAL_VIDEO_TRACK_ID", "v8"],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", "opus"],
["REMOTE_VIDEO_TRACK_ID", "v9"],
]),
},
framerate: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", 0],
["LOCAL_VIDEO_TRACK_ID", 30],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", 0],
["REMOTE_VIDEO_TRACK_ID", 60],
]),
},
packetLoss: {
download: 7,
total: 15,
upload: 28,
},
resolution: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
]),
},
jitter: new Map([
["REMOTE_AUDIO_TRACK_ID", 0.1],
["REMOTE_VIDEO_TRACK_ID", 50],
]),
});
});
});
const buildData = (): void => {
localAudioTrack.setCodec("opus");
localAudioTrack.setLoss({ packetsTotal: 10, packetsLost: 5, isDownloadStream: false });
localAudioTrack.setBitrate({ download: 0, upload: 5000 });
remoteAudioTrack.setCodec("opus");
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
remoteAudioTrack.setJitter(0.1);
localVideoTrack.setCodec("v8");
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
localVideoTrack.setBitrate({ download: 0, upload: 3000000 });
localVideoTrack.setFramerate(30);
localVideoTrack.setResolution({ width: 780, height: 460 });
remoteVideoTrack.setCodec("v9");
remoteVideoTrack.setLoss({ packetsTotal: 40, packetsLost: 4, isDownloadStream: true });
remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 });
remoteVideoTrack.setFramerate(60);
remoteVideoTrack.setResolution({ width: 1080, height: 960 });
remoteVideoTrack.setJitter(50);
};
});
@@ -0,0 +1,65 @@
/*
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 { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import {
ByteSentStatsReport,
ConnectionStatsReport,
StatsReport,
SummaryStatsReport,
} from "../../../../src/webrtc/stats/statsReport";
describe("StatsReportEmitter", () => {
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
});
it("should emit and receive ByteSendStatsReport", async () => {
const report = {} as ByteSentStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.BYTE_SENT_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitByteSendReport(report);
});
});
it("should emit and receive ConnectionStatsReport", async () => {
const report = {} as ConnectionStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.CONNECTION_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
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);
});
});
});
@@ -0,0 +1,84 @@
/*
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 { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("StatsReportGatherer", () => {
let collector: StatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
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 },
videoTrackSummary: { count: 0, muted: 0 },
});
expect(collector.getActive()).toBeTruthy();
});
it("if not active do not calculate stats reports", async () => {
collector.setActive(false);
const getStats = jest.spyOn(rtcSpy, "getStats");
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).not.toHaveBeenCalled();
});
it("if get reports fails, the collector becomes inactive", async () => {
expect(collector.getActive()).toBeTruthy();
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockRejectedValue(new Error("unknown"));
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual({
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 0, muted: 0 },
videoTrackSummary: { count: 0, muted: 0 },
});
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
});
});
@@ -0,0 +1,28 @@
/*
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 { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
describe("StatsValueFormatter", () => {
describe("on get non negative values", () => {
it("formatter shod return number", async () => {
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
});
});
});
@@ -0,0 +1,127 @@
/*
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 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0.5,
percentageReceivedAudioMedia: 0.5,
percentageReceivedVideoMedia: 0.75,
});
});
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 },
videoTrackSummary: { count: 1, muted: 1 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
});
});
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 },
videoTrackSummary: { count: 2, muted: 1 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 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 },
videoTrackSummary: { count: 1, muted: 0 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 0,
percentageReceivedVideoMedia: 1,
});
});
});
});
@@ -0,0 +1,275 @@
/*
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 { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
describe("TrackStatsReporter", () => {
describe("should on frame and resolution stats", () => {
it("creating empty frame and resolution report, if no data available.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {});
expect(trackStats.getFramerate()).toEqual(0);
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
});
it("creating empty frame and resolution report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {
framesPerSecond: 22.2,
frameHeight: 180,
frameWidth: 360,
});
expect(trackStats.getFramerate()).toEqual(22);
expect(trackStats.getResolution()).toEqual({ width: 360, height: 180 });
});
});
describe("should on simulcast", () => {
it("creating simulcast framerate.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.calculateSimulcastFramerate(
trackStats,
{
framesSent: 100,
timestamp: 1678957001000,
},
{
framesSent: 10,
timestamp: 1678957000000,
},
3,
);
expect(trackStats.getFramerate()).toEqual(30);
});
});
describe("should on bytes received stats", () => {
it("creating build bitrate received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildBitrateReceived(
trackStats,
{
bytesReceived: 2001000,
timestamp: 1678957010,
},
{ bytesReceived: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 800, upload: 0 });
});
});
describe("should on bytes send stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildBitrateSend(
trackStats,
{
bytesSent: 2001000,
timestamp: 1678957010,
},
{ bytesSent: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 0, upload: 800 });
});
});
describe("should on codec stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const remote = {} as RTCStatsReport;
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
expect(trackStats.getCodec()).toEqual("v8");
});
});
describe("should on package lost stats", () => {
it("creating build package lost on send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildPacketsLost(
trackStats,
{
type: "outbound-rtp",
packetsSent: 200,
packetsLost: 120,
},
{
packetsSent: 100,
packetsLost: 30,
},
);
expect(trackStats.getLoss()).toEqual({ packetsTotal: 190, packetsLost: 90, isDownloadStream: false });
});
it("creating build package lost on received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildPacketsLost(
trackStats,
{
type: "inbound-rtp",
packetsReceived: 300,
packetsLost: 100,
},
{
packetsReceived: 100,
packetsLost: 20,
},
);
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,
},
videoTrackSummary: {
count: 0,
muted: 0,
},
});
});
it("and returns summary if stats list not empty", async () => {
const summary = TrackStatsReporter.buildTrackSummary([]);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
muted: 0,
},
videoTrackSummary: {
count: 0,
muted: 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);
});
});
});
@@ -0,0 +1,126 @@
/*
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 { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
describe("TransportStatsReporter", () => {
describe("should on build report", () => {
const REMOTE_CANDIDATE_ID = "REMOTE_CANDIDATE_ID";
const LOCAL_CANDIDATE_ID = "LOCAL_CANDIDATE_ID";
const localIC = { ip: "88.88.99.1", port: 56670, protocol: "tcp", candidateType: "local", networkType: "lan" };
const remoteIC = {
ip: "123.88.99.1",
port: 46670,
protocol: "udp",
candidateType: "srfx",
networkType: "wifi",
};
const isFocus = false;
const rtt = 200000;
it("build new transport stats if all properties there", () => {
const { report, stats } = mockStatsReport(isFocus, 0);
const conferenceStatsTransport: TransportStats[] = [];
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build next transport stats if candidates different", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 1);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
{
ip: `${remoteIC.ip + 1}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 1}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build not a second transport stats if candidates the same", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 0);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
const mockStatsReport = (
isFocus: boolean,
prifix: number,
): { report: RTCStatsReport; stats: RTCIceCandidatePairStats } => {
const report = {} as RTCStatsReport;
report.get = (key: string) => {
if (key === LOCAL_CANDIDATE_ID) {
return { ...localIC, ip: localIC.ip + prifix };
}
if (key === REMOTE_CANDIDATE_ID) {
return { ...remoteIC, ip: remoteIC.ip + prifix };
}
// remote
return {};
};
const stats = {
remoteCandidateId: REMOTE_CANDIDATE_ID,
localCandidateId: LOCAL_CANDIDATE_ID,
currentRoundTripTime: 200,
} as RTCIceCandidatePairStats;
return { report, stats };
};
});
});
+9
View File
@@ -63,6 +63,7 @@ export function isDmMemberCountCondition(condition: AnyMemberCountCondition): bo
export enum ConditionKind {
EventMatch = "event_match",
EventPropertyIs = "event_property_is",
EventPropertyContains = "event_property_contains",
ContainsDisplayName = "contains_display_name",
RoomMemberCount = "room_member_count",
SenderNotificationPermission = "sender_notification_permission",
@@ -88,6 +89,11 @@ export interface IEventPropertyIsCondition extends IPushRuleCondition<ConditionK
value: string | boolean | null | number;
}
export interface IEventPropertyContainsCondition extends IPushRuleCondition<ConditionKind.EventPropertyContains> {
key: string;
value: string | boolean | null | number;
}
export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> {
// no additional fields
}
@@ -114,6 +120,7 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition<Conditio
export type PushRuleCondition =
| IEventMatchCondition
| IEventPropertyIsCondition
| IEventPropertyContainsCondition
| IContainsDisplayNameCondition
| IRoomMemberCountCondition
| ISenderNotificationPermissionCondition
@@ -130,6 +137,8 @@ export enum PushRuleKind {
export enum RuleId {
Master = ".m.rule.master",
IsUserMention = ".org.matrix.msc3952.is_user_mention",
IsRoomMention = ".org.matrix.msc3952.is_room_mention",
ContainsDisplayName = ".m.rule.contains_display_name",
ContainsUserName = ".m.rule.contains_user_name",
AtRoomNotification = ".m.rule.roomnotif",
+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;
}
+3 -6
View File
@@ -38,7 +38,7 @@ export interface CachedReceipt {
data: Receipt;
}
export type ReceiptCache = { [eventId: string]: CachedReceipt[] };
export type ReceiptCache = Map<string, CachedReceipt[]>;
export interface ReceiptContent {
[eventId: string]: {
@@ -49,11 +49,8 @@ export interface ReceiptContent {
}
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
export type Receipts = {
[receiptType: string]: {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
// map: receipt type → user Id → receipt
export type Receipts = Map<string, Map<string, [real: WrappedReceipt | null, synthetic: WrappedReceipt | null]>>;
export type CachedReceiptStructure = {
eventId: string;
+3 -5
View File
@@ -21,6 +21,7 @@ import { MatrixError } from "./http-api";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
import { SyncState } from "./sync";
import { MapWithDefault } from "./utils";
const MAX_BATCH_SIZE = 20;
@@ -122,12 +123,9 @@ export class ToDeviceMessageQueue {
* Attempts to send a batch of to-device messages.
*/
private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
const contentMap: Record<string, Record<string, ToDevicePayload>> = {};
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
for (const item of batch.batch) {
if (!contentMap[item.userId]) {
contentMap[item.userId] = {};
}
contentMap[item.userId][item.deviceId] = item.payload;
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
}
logger.info(
+220 -89
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 } 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";
@@ -79,7 +80,7 @@ import {
VerificationMethod,
IRoomKeyRequestBody,
} from "./crypto";
import { DeviceInfo, IDevice } from "./crypto/deviceinfo";
import { DeviceInfo } from "./crypto/deviceinfo";
import { decodeRecoveryKey } from "./crypto/recoverykey";
import { keyFromAuthData } from "./crypto/key_passphrase";
import { User, UserEvent, UserEventHandlerMap } from "./models/user";
@@ -101,12 +102,10 @@ import { IAuthData, IAuthDict } from "./interactive-auth";
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
import {
CrossSigningKey,
IAddSecretStorageKeyOpts,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
IRecoveryKey,
ISecretStorageKeyInfo,
} from "./crypto/api";
import { EventTimelineSet } from "./models/event-timeline-set";
import { VerificationRequest } from "./crypto/verification/request/VerificationRequest";
@@ -206,6 +205,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 { DeviceInfoMap } from "./crypto/DeviceList";
import {
AddSecretStorageKeyOpts,
SecretStorageKeyDescription,
ServerSideSecretStorage,
ServerSideSecretStorageImpl,
} from "./secret-storage";
export type Store = IStore;
@@ -460,6 +467,11 @@ export interface IStartClientOpts {
* @experimental
*/
slidingSync?: SlidingSync;
/**
* @experimental
*/
intentionalMentions?: boolean;
}
export interface IStoredClientOpts extends IStartClientOpts {}
@@ -482,11 +494,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 */
@@ -505,6 +527,8 @@ enum CrossSigningKeyType {
export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>;
export interface ISignedKey {
keys: Record<string, string>;
signatures: ISignatures;
@@ -836,7 +860,7 @@ interface IRoomHierarchy {
next_batch?: string;
}
interface ITimestampToEventResponse {
export interface TimestampToEventResponse {
event_id: string;
origin_server_ts: string;
}
@@ -1166,7 +1190,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
public identityServer?: IIdentityServerProvider;
public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
public crypto?: Crypto; // libolm crypto implementation. XXX: Intended private, used in code. Being replaced by cryptoBackend
/**
* The libolm crypto implementation, if it is in use.
*
* @deprecated This should not be used. Instead, use the methods exposed directly on this class or
* (where they are available) via {@link getCrypto}.
*/
public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend
private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
@@ -1209,7 +1241,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected serverVersionsPromise?: Promise<IServerVersions>;
public cachedCapabilities?: {
capabilities: ICapabilities;
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown;
@@ -1226,6 +1258,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;
@@ -1391,6 +1425,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
this.ignoredInvites = new IgnoredInvites(this);
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
}
/**
@@ -2028,7 +2063,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) {
@@ -2039,7 +2074,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
type Response = {
capabilities?: ICapabilities;
capabilities?: Capabilities;
};
return this.http
.authedRequest<Response>(Method.Get, "/capabilities")
@@ -2200,9 +2235,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
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.
*
* If end-to-end encryption has been enabled for this client (via {@link initCrypto} or {@link initRustCrypto}),
* returns an object giving access to the crypto API. Otherwise, returns `undefined`.
*/
public getCrypto(): CryptoApi | undefined {
return this.cryptoBackend;
}
/**
* Is end-to-end crypto enabled for this client.
* @returns True if end-to-end is enabled.
* @deprecated prefer {@link getCrypto}
*/
public isCryptoEnabled(): boolean {
return !!this.cryptoBackend;
@@ -2241,9 +2294,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`
*/
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<Record<string, Record<string, IDevice>>> {
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
if (!this.crypto) {
return Promise.reject(new Error("End-to-end encryption disabled"));
}
@@ -2435,11 +2488,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.crypto.beginKeyVerification(method, userId, deviceId);
}
public checkSecretStorageKey(key: Uint8Array, info: ISecretStorageKeyInfo): Promise<boolean> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.checkSecretStorageKey(key, info);
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
*/
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
return this.secretStorage.checkKey(key, info);
}
/**
@@ -2448,6 +2501,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* do not specify a value.
*
* @param value - whether to blacklist all unverified devices by default
*
* @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
*
* ```javascript
* client.getCrypto().globalBlacklistUnverifiedDevices = value;
* ```
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
if (!this.cryptoBackend) {
@@ -2459,6 +2518,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns whether to blacklist all unverified devices by default
*
* @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
*
* ```javascript
* value = client.getCrypto().globalBlacklistUnverifiedDevices;
* ```
*/
public getGlobalBlacklistUnverifiedDevices(): boolean {
if (!this.cryptoBackend) {
@@ -2476,6 +2541,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* This API is currently UNSTABLE and may change or be removed without notice.
*
* @param value - whether error on unknown devices
*
* @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
*
* ```ts
* client.getCrypto().globalBlacklistUnverifiedDevices = value;
* ```
*/
public setGlobalErrorOnUnknownDevices(value: boolean): void {
if (!this.cryptoBackend) {
@@ -2535,8 +2606,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - The ID of the user to check.
*
* @returns
*/
public checkUserTrust(userId: string): UserTrustLevel {
if (!this.cryptoBackend) {
@@ -2618,6 +2687,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
* @param room - the room the event is in
*
* @deprecated Prefer {@link CryptoApi.prepareToEncrypt | `CryptoApi.prepareToEncrypt`}:
*
* ```javascript
* client.getCrypto().prepareToEncrypt(room);
* ```
*/
public prepareToEncrypt(room: Room): void {
if (!this.cryptoBackend) {
@@ -2631,6 +2706,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* This means downloading the devicelist for the user and checking if the list includes
* the cross-signing pseudo-device.
*
* @deprecated Prefer {@link CryptoApi.userHasCrossSigningKeys | `CryptoApi.userHasCrossSigningKeys`}:
*
* ```javascript
* result = client.getCrypto().userHasCrossSigningKeys();
* ```
*/
public userHasCrossSigningKeys(): Promise<boolean> {
if (!this.cryptoBackend) {
@@ -2802,16 +2883,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: ISecretStorageKeyInfo }> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.addSecretStorageKey(algorithm, opts, keyName);
): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> {
return this.secretStorage.addKey(algorithm, opts, keyName);
}
/**
@@ -2822,12 +2902,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);
}
/**
@@ -2839,12 +2918,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);
}
/**
@@ -2855,12 +2933,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);
}
/**
@@ -2872,12 +2949,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, ISecretStorageKeyInfo> | null> {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isSecretStored(name);
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
return this.secretStorage.isStored(name);
}
/**
@@ -2903,12 +2979,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();
}
/**
@@ -2917,12 +2992,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);
}
/**
@@ -2935,6 +3009,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) {
@@ -3074,13 +3151,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param roomId - The ID of the room to discard the session for
*
* This should not normally be necessary.
* @deprecated Prefer {@link CryptoApi.forceDiscardSession | `CryptoApi.forceDiscardSession`}:
*
*/
public forceDiscardSession(roomId: string): void {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-End encryption disabled");
}
this.crypto.forceDiscardSession(roomId);
this.cryptoBackend.forceDiscardSession(roomId);
}
/**
@@ -3088,8 +3166,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a list of
* session export objects
* @returns a promise which resolves to a list of session export objects
*
* @deprecated Prefer {@link CryptoApi.exportRoomKeys | `CryptoApi.exportRoomKeys`}:
*
* ```javascript
* sessionData = await client.getCrypto().exportRoomKeys();
* ```
*/
public exportRoomKeys(): Promise<IMegolmSessionData[]> {
if (!this.cryptoBackend) {
@@ -3225,7 +3308,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");
}
@@ -3244,8 +3327,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* encrypted with, or null if it is not present or not encrypted with a
* trusted key
*/
public isKeyBackupKeyStored(): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> {
return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1"));
}
/**
@@ -3510,14 +3593,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!);
@@ -3760,9 +3843,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const deviceInfos = await this.crypto.downloadKeys(userIds);
const devicesByUser: Record<string, DeviceInfo[]> = {};
for (const [userId, devices] of Object.entries(deviceInfos)) {
devicesByUser[userId] = Object.values(devices);
const devicesByUser: Map<string, DeviceInfo[]> = new Map();
for (const [userId, devices] of deviceInfos) {
devicesByUser.set(userId, Array.from(devices.values()));
}
// XXX: Private member access
@@ -5303,11 +5386,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.
@@ -5977,10 +6077,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const token = res.next_token;
const matrixEvents: MatrixEvent[] = [];
res.notifications = res.notifications.filter(noUnsafeEventProps);
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;
}
@@ -6023,11 +6129,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
.then((res) => {
if (res.state) {
const roomState = eventTimeline.getState(dir)!;
const stateEvents = res.state.map(this.getEventMapper());
const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
const token = res.end;
const matrixEvents = res.chunk.map(this.getEventMapper());
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
@@ -6059,7 +6165,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
})
.then(async (res) => {
const mapper = this.getEventMapper();
const matrixEvents = res.chunk.map(mapper);
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
// Process latest events first
for (const event of matrixEvents.slice().reverse()) {
@@ -6107,11 +6213,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
.then((res) => {
if (res.state) {
const roomState = eventTimeline.getState(dir)!;
const stateEvents = res.state.map(this.getEventMapper());
const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
const token = res.end;
const matrixEvents = res.chunk.map(this.getEventMapper());
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
@@ -7398,7 +7504,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);
}
@@ -7745,15 +7851,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?: IAuthData): 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;
}
/**
@@ -8522,7 +8646,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setPushRules(rules: IPushRules): void {
// Fix-up defaults, if applicable.
this.pushRules = PushProcessor.rewriteDefaultRules(rules);
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
// Pre-calculate any necessary caches.
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
}
@@ -8685,8 +8809,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) {
@@ -9129,24 +9253,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* supplied.
* @returns Promise which resolves: to an empty object `{}`
*/
public sendToDevice(
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
): Promise<{}> {
public sendToDevice(eventType: string, contentMap: SendToDeviceContentMap, txnId?: string): Promise<{}> {
const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
$eventType: eventType,
$txnId: txnId ? txnId : this.makeTxnId(),
});
const body = {
messages: contentMap,
messages: utils.recursiveMapToObject(contentMap),
};
const targets = Object.keys(contentMap).reduce<Record<string, string[]>>((obj, key) => {
obj[key] = Object.keys(contentMap[key]);
return obj;
}, {});
const targets = new Map<string, string[]>();
for (const [userId, deviceMessages] of contentMap) {
targets.set(userId, Array.from(deviceMessages.keys()));
}
logger.log(`PUT ${path}`, targets);
return this.http.authedRequest(Method.Put, path, undefined, body);
@@ -9419,6 +9541,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.clientOpts?.threadSupport || false;
}
/**
* A helper to determine intentional mentions support
* @returns a boolean to determine if intentional mentions are enabled
* @experimental
*/
public supportsIntentionalMentions(): boolean {
return this.clientOpts?.intentionalMentions || false;
}
/**
* Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
@@ -9488,7 +9619,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
timestamp: number,
dir: Direction,
): Promise<ITimestampToEventResponse> {
): Promise<TimestampToEventResponse> {
const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
$roomId: roomId,
});
+24 -41
View File
@@ -14,31 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
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 { IEncryptedEventInfo } from "../crypto/api";
import { IEventDecryptionResult } from "../@types/crypto";
/**
* Common interface for the crypto implementations
*/
export interface CryptoBackend extends SyncCryptoCallbacks {
/**
* Global override for whether the client should ever send encrypted
* messages to unverified devices. This provides the default for rooms which
* do not specify a value.
*
* If true, all unverified devices will be blacklisted by default
*/
globalBlacklistUnverifiedDevices: boolean;
export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
/**
* Whether sendMessage in a room with unknown and unverified devices
* should throw an error and not send the message. This has 'Global' for
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
* no room-level equivalent for this setting.
*
* @remarks this is here, rather than in `CryptoApi`, because I don't think we're
* going to support it in the rust crypto implementation.
*/
globalErrorOnUnknownDevices: boolean;
@@ -47,16 +42,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
stop(): void;
/**
* Checks if the user has previously published cross-signing keys
*
* This means downloading the devicelist for the user and checking if the list includes
* the cross-signing pseudo-device.
* @returns true if the user has previously published cross-signing keys
*/
userHasCrossSigningKeys(): Promise<boolean>;
/**
* Get the verification level for a given user
*
@@ -76,14 +61,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
*/
prepareToEncrypt(room: Room): void;
/**
* Encrypt an event according to the configuration of the room.
*
@@ -110,16 +87,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
* @param event - event to be checked
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Get a list containing all of the room keys
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a list of
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
}
/** The methods which crypto implementations should expose to the Sync api */
@@ -138,6 +105,22 @@ export interface SyncCryptoCallbacks {
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* 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
* @param unusedFallbackKeys - the received unused fallback keys
*/
processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void>;
/**
* Handle the notification from /sync that device lists have
* been changed.
*
* @param deviceLists - device_lists field from /sync
*/
processDeviceLists(deviceLists: IDeviceLists): Promise<void>;
/**
* Called by the /sync loop whenever an m.room.encryption event is received.
*
+75
View File
@@ -0,0 +1,75 @@
/*
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 { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
/**
* Public interface to the cryptography parts of the js-sdk
*
* @remarks Currently, this is a work-in-progress. In time, more methods will be added here.
*/
export interface CryptoApi {
/**
* Global override for whether the client should ever send encrypted
* messages to unverified devices. This provides the default for rooms which
* do not specify a value.
*
* If true, all unverified devices will be blacklisted by default
*/
globalBlacklistUnverifiedDevices: boolean;
/**
* Checks if the user has previously published cross-signing keys
*
* This means downloading the devicelist for the user and checking if the list includes
* the cross-signing pseudo-device.
*
* @returns true if the user has previously published cross-signing keys
*/
userHasCrossSigningKeys(): Promise<boolean>;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
*/
prepareToEncrypt(room: Room): void;
/**
* Discard any existing megolm session for the given room.
*
* This will ensure that a new session is created on the next call to {@link prepareToEncrypt},
* or the next time a message is sent.
*
* This should not normally be necessary: it should only be used as a debugging tool if there has been a
* problem with encryption.
*
* @param roomId - the room to discard sessions for
*/
forceDiscardSession(roomId: string): Promise<void>;
/**
* Get a list containing all of the room keys
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a list of
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
}
+8 -6
View File
@@ -25,13 +25,12 @@ 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 { ISecretStorageKeyInfo } from "./api";
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
@@ -164,12 +163,12 @@ 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")) || {};
// then check which of those SSSS keys have also encrypted the SSK and USK
function intersect(s: Record<string, ISecretStorageKeyInfo>): void {
function intersect(s: Record<string, SecretStorageKeyDescription>): void {
for (const k of Object.keys(stored)) {
if (!s[k]) {
delete stored[k];
@@ -192,7 +191,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 +208,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;
+8 -7
View File
@@ -58,7 +58,8 @@ export enum TrackingStatus {
UpToDate,
}
export type DeviceInfoMap = Record<string, Record<string, DeviceInfo>>;
// user-Id → device-Id → DeviceInfo
export type DeviceInfoMap = Map<string, Map<string, DeviceInfo>>;
type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated;
@@ -301,13 +302,13 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
* @returns userId-\>deviceId-\>{@link DeviceInfo}.
*/
private getDevicesFromStore(userIds: string[]): DeviceInfoMap {
const stored: DeviceInfoMap = {};
userIds.forEach((u) => {
stored[u] = {};
const devices = this.getStoredDevicesForUser(u) || [];
devices.forEach(function (dev) {
stored[u][dev.deviceId] = dev;
const stored: DeviceInfoMap = new Map();
userIds.forEach((userId) => {
const deviceMap = new Map();
this.getStoredDevicesForUser(userId)?.forEach(function (device) {
deviceMap.set(device.deviceId, device);
});
stored.set(userId, deviceMap);
});
return stored;
}
+13 -14
View File
@@ -15,7 +15,7 @@ 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";
@@ -28,10 +28,9 @@ import {
ISignedKey,
KeySignatures,
} from "../client";
import { ISecretStorageKeyInfo } from "./api";
import { IKeyBackupInfo } from "./keybackup";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IAccountDataClient } from "./SecretStorage";
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
interface ICrossSigningKeys {
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
@@ -61,7 +60,7 @@ export class EncryptionSetupBuilder {
* @param accountData - pre-existing account data, will only be read, not written.
* @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet
*/
public constructor(accountData: Record<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) {
public constructor(accountData: Map<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) {
this.accountDataClientAdapter = new AccountDataClientAdapter(accountData);
this.crossSigningCallbacks = new CrossSigningCallbacks();
this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks);
@@ -238,7 +237,7 @@ export class EncryptionSetupOperation {
*/
class AccountDataClientAdapter
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
implements IAccountDataClient
implements AccountDataClient
{
//
public readonly values = new Map<string, MatrixEvent>();
@@ -246,28 +245,28 @@ class AccountDataClientAdapter
/**
* @param existingValues - existing account data
*/
public constructor(private readonly existingValues: Record<string, MatrixEvent>) {
public constructor(private readonly existingValues: Map<string, MatrixEvent>) {
super();
}
/**
* @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[type];
const existingValue = this.existingValues.get(type);
if (existingValue) {
return existingValue.getContent();
return existingValue.getContent<T>();
}
return null;
}
@@ -326,7 +325,7 @@ class SSSSCryptoCallbacks {
public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {}
public async getSecretStorageKey(
{ keys }: { keys: Record<string, ISecretStorageKeyInfo> },
{ keys }: { keys: Record<string, SecretStorageKeyDescription> },
name: string,
): Promise<[string, Uint8Array] | null> {
for (const keyId of Object.keys(keys)) {
@@ -348,7 +347,7 @@ class SSSSCryptoCallbacks {
return null;
}
public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void {
public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void {
this.privateKeys.set(keyId, privKey);
// Also pass along to application to cache if it wishes
this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey);
+5 -6
View File
@@ -21,6 +21,7 @@ import { MatrixClient } from "../client";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index";
import { CryptoStore, OutgoingRoomKeyRequest } from "./store/base";
import { EventType, ToDeviceMessageId } from "../@types/event";
import { MapWithDefault } from "../utils";
/**
* Internal module. Management of outgoing room key requests.
@@ -460,15 +461,13 @@ export class OutgoingRoomKeyRequestManager {
recipients: IRoomKeyRequestRecipient[],
txnId?: string,
): Promise<{}> {
const contentMap: Record<string, Record<string, Record<string, any>>> = {};
const contentMap = new MapWithDefault<string, Map<string, Record<string, any>>>(() => new Map());
for (const recip of recipients) {
if (!contentMap[recip.userId]) {
contentMap[recip.userId] = {};
}
contentMap[recip.userId][recip.deviceId] = {
const userDeviceMap = contentMap.getOrCreate(recip.userId);
userDeviceMap.set(recip.deviceId, {
...message,
[ToDeviceMessageId]: uuidv4(),
};
});
}
return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId);
+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);
}
}
}
+60 -508
View File
@@ -14,575 +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, ISecretStorageKeyInfo } from "./api";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { defer, IDeferred } from "../utils";
import { ToDeviceMessageId } from "../@types/event";
/* 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: ISecretStorageKeyInfo];
export type SecretStorageKeyObject = { keyId: string; keyInfo: ISecretStorageKeyInfo };
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> {
const keyInfo = { algorithm } as ISecretStorageKeyInfo;
if (opts.name) {
keyInfo.name = opts.name;
}
if (algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
if (opts.passphrase) {
keyInfo.passphrase = opts.passphrase;
}
if (opts.key) {
const { iv, mac } = await calculateKeyCheck(opts.key);
keyInfo.iv = iv;
keyInfo.mac = mac;
}
} else {
throw new Error(`Unknown key algorithm ${algorithm}`);
}
if (!keyId) {
do {
keyId = randomString(32);
} while (
await this.accountDataAdapter.getAccountDataFromServer<ISecretStorageKeyInfo>(
`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<ISecretStorageKeyInfo>(
"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: ISecretStorageKeyInfo): 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<ISecretStorageKeyInfo>(
"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, ISecretStorageKeyInfo> = {};
for (const keyId of Object.keys(secretInfo.encrypted)) {
// get key information from key storage
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretStorageKeyInfo>(
"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, ISecretStorageKeyInfo> | null> {
// check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo?.encrypted) return null;
const ret: Record<string, ISecretStorageKeyInfo> = {};
// 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<ISecretStorageKeyInfo>(
"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;
public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | 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: Record<string, typeof cancelData> = {};
for (const device of devices) {
toDevice[device] = cancelData;
}
this.baseApis.sendToDevice("m.secret.request", {
[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: Record<string, typeof requestData> = {};
for (const device of devices) {
toDevice[device] = requestData;
}
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
this.baseApis.sendToDevice("m.secret.request", {
[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, {
[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 = {
[sender]: {
[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, ISecretStorageKeyInfo>,
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);
}
}
+3 -6
View File
@@ -26,6 +26,7 @@ import { IContent, MatrixEvent, RoomMember } from "../../matrix";
import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo";
import { IRoomEncryption } from "../RoomList";
import { DeviceInfoMap } from "../DeviceList";
/**
* Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class
@@ -195,7 +196,7 @@ export abstract class DecryptionAlgorithm {
}
public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>;
public sendSharedHistoryInboundSessions?(devicesByUser: Record<string, DeviceInfo[]>): Promise<void>;
public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
}
/**
@@ -241,11 +242,7 @@ export class UnknownDeviceError extends Error {
* @param msg - message describing the problem
* @param devices - set of unknown devices per user we're warning about
*/
public constructor(
msg: string,
public readonly devices: Record<string, Record<string, object>>,
public event?: MatrixEvent,
) {
public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) {
super(msg);
this.name = "UnknownDeviceError";
this.devices = devices;
+131 -161
View File
@@ -43,7 +43,7 @@ import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } fr
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api";
import { immediate } from "../../utils";
import { immediate, MapWithDefault } from "../../utils";
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@@ -63,17 +63,27 @@ interface IBlockedDevice {
deviceInfo: DeviceInfo;
}
interface IBlockedMap {
[userId: string]: {
[deviceId: string]: IBlockedDevice;
};
}
// map user Id → device Id → IBlockedDevice
type BlockedMap = Map<string, Map<string, IBlockedDevice>>;
export interface IOlmDevice<T = DeviceInfo> {
userId: string;
deviceInfo: T;
}
/**
* Tests whether an encrypted content has a ciphertext.
* Ciphertext can be a string or object depending on the content type {@link IEncryptedContent}.
*
* @param content - Encrypted content
* @returns true: has ciphertext, else false
*/
const hasCiphertext = (content: IEncryptedContent): boolean => {
return typeof content.ciphertext === "string"
? !!content.ciphertext.length
: !!Object.keys(content.ciphertext).length;
};
/** The result of parsing the an `m.room_key` or `m.forwarded_room_key` to-device event */
interface RoomKey {
/**
@@ -147,8 +157,8 @@ class OutboundSessionInfo {
/** when the session was created (ms since the epoch) */
public creationTime: number;
/** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */
public sharedWithDevices: Record<string, Record<string, SharedWithData>> = {};
public blockedDevicesNotified: Record<string, Record<string, boolean>> = {};
public sharedWithDevices: MapWithDefault<string, Map<string, SharedWithData>> = new MapWithDefault(() => new Map());
public blockedDevicesNotified: MapWithDefault<string, Map<string, boolean>> = new MapWithDefault(() => new Map());
/**
* @param sharedHistory - whether the session can be freely shared with
@@ -173,17 +183,11 @@ class OutboundSessionInfo {
}
public markSharedWithDevice(userId: string, deviceId: string, deviceKey: string, chainIndex: number): void {
if (!this.sharedWithDevices[userId]) {
this.sharedWithDevices[userId] = {};
}
this.sharedWithDevices[userId][deviceId] = { deviceKey, messageIndex: chainIndex };
this.sharedWithDevices.getOrCreate(userId).set(deviceId, { deviceKey, messageIndex: chainIndex });
}
public markNotifiedBlockedDevice(userId: string, deviceId: string): void {
if (!this.blockedDevicesNotified[userId]) {
this.blockedDevicesNotified[userId] = {};
}
this.blockedDevicesNotified[userId][deviceId] = true;
this.blockedDevicesNotified.getOrCreate(userId).set(deviceId, true);
}
/**
@@ -196,23 +200,15 @@ class OutboundSessionInfo {
* @returns true if we have shared the session with devices which aren't
* in devicesInRoom.
*/
public sharedWithTooManyDevices(devicesInRoom: Record<string, Record<string, object>>): boolean {
for (const userId in this.sharedWithDevices) {
if (!this.sharedWithDevices.hasOwnProperty(userId)) {
continue;
}
if (!devicesInRoom.hasOwnProperty(userId)) {
public sharedWithTooManyDevices(devicesInRoom: DeviceInfoMap): boolean {
for (const [userId, devices] of this.sharedWithDevices) {
if (!devicesInRoom.has(userId)) {
logger.log("Starting new megolm session because we shared with " + userId);
return true;
}
for (const deviceId in this.sharedWithDevices[userId]) {
if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) {
continue;
}
if (!devicesInRoom[userId].hasOwnProperty(deviceId)) {
for (const [deviceId] of devices) {
if (!devicesInRoom.get(userId)?.get(deviceId)) {
logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId);
return true;
}
@@ -292,7 +288,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
private async ensureOutboundSession(
room: Room,
devicesInRoom: DeviceInfoMap,
blocked: IBlockedMap,
blocked: BlockedMap,
singleOlmCreationPhase = false,
): Promise<OutboundSessionInfo> {
// takes the previous OutboundSessionInfo, and considers whether to create
@@ -360,21 +356,21 @@ export class MegolmEncryption extends EncryptionAlgorithm {
devicesInRoom: DeviceInfoMap,
sharedHistory: boolean,
singleOlmCreationPhase: boolean,
blocked: IBlockedMap,
blocked: BlockedMap,
session: OutboundSessionInfo,
): Promise<void> {
// now check if we need to share with any devices
const shareMap: Record<string, DeviceInfo[]> = {};
for (const [userId, userDevices] of Object.entries(devicesInRoom)) {
for (const [deviceId, deviceInfo] of Object.entries(userDevices)) {
for (const [userId, userDevices] of devicesInRoom) {
for (const [deviceId, deviceInfo] of userDevices) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (!session.sharedWithDevices[userId] || session.sharedWithDevices[userId][deviceId] === undefined) {
if (!session.sharedWithDevices.get(userId)?.get(deviceId)) {
shareMap[userId] = shareMap[userId] || [];
shareMap[userId].push(deviceInfo);
}
@@ -402,9 +398,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
await Promise.all([
(async (): Promise<void> => {
// share keys with devices that we already have a session for
const olmSessionList = Object.entries(olmSessions)
const olmSessionList = Array.from(olmSessions.entries())
.map(([userId, sessionsByUser]) =>
Object.entries(sessionsByUser).map(
Array.from(sessionsByUser.entries()).map(
([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`,
),
)
@@ -414,7 +410,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
this.prefixedLogger.debug("Shared keys with existing Olm sessions");
})(),
(async (): Promise<void> => {
const deviceList = Object.entries(devicesWithoutSession)
const deviceList = Array.from(devicesWithoutSession.entries())
.map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`))
.flat(1);
this.prefixedLogger.debug(
@@ -450,7 +446,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// do this in the background and don't block anything else while we
// do this. We only need to retry users from servers that didn't
// respond the first time.
const retryDevices: Record<string, DeviceInfo[]> = {};
const retryDevices: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []);
const failedServerMap = new Set();
for (const server of failedServers) {
failedServerMap.add(server);
@@ -459,8 +455,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
for (const { userId, deviceInfo } of errorDevices) {
const userHS = userId.slice(userId.indexOf(":") + 1);
if (failedServerMap.has(userHS)) {
retryDevices[userId] = retryDevices[userId] || [];
retryDevices[userId].push(deviceInfo);
retryDevices.getOrCreate(userId).push(deviceInfo);
} else {
// if we aren't going to retry, then handle it
// as a failed device
@@ -468,7 +463,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
}
const retryDeviceList = Object.entries(retryDevices)
const retryDeviceList = Array.from(retryDevices.entries())
.map(([userId, devicesByUser]) =>
devicesByUser.map((device) => `${userId}/${device.deviceId}`),
)
@@ -493,25 +488,25 @@ export class MegolmEncryption extends EncryptionAlgorithm {
})(),
(async (): Promise<void> => {
this.prefixedLogger.debug(
`There are ${Object.entries(blocked).length} blocked devices:`,
Object.entries(blocked)
`There are ${blocked.size} blocked devices:`,
Array.from(blocked.entries())
.map(([userId, blockedByUser]) =>
Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`),
Array.from(blockedByUser.entries()).map(
([deviceId, _deviceInfo]) => `${userId}/${deviceId}`,
),
)
.flat(1),
);
// also, notify newly blocked devices that they're blocked
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault(
() => new Map(),
);
let blockedCount = 0;
for (const [userId, userBlockedDevices] of Object.entries(blocked)) {
for (const [deviceId, device] of Object.entries(userBlockedDevices)) {
if (
!session.blockedDevicesNotified[userId] ||
session.blockedDevicesNotified[userId][deviceId] === undefined
) {
blockedMap[userId] = blockedMap[userId] || {};
blockedMap[userId][deviceId] = { device };
for (const [userId, userBlockedDevices] of blocked) {
for (const [deviceId, device] of userBlockedDevices) {
if (session.blockedDevicesNotified.get(userId)?.get(deviceId) === undefined) {
blockedMap.getOrCreate(userId).set(deviceId, { device });
blockedCount++;
}
}
@@ -520,7 +515,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
if (blockedCount) {
this.prefixedLogger.debug(
`Notifying ${blockedCount} newly blocked devices:`,
Object.entries(blockedMap)
Array.from(blockedMap.entries())
.map(([userId, blockedByUser]) =>
Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`),
)
@@ -566,7 +561,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
*
* @internal
*
* @param devicemap - the devices that have olm sessions, as returned by
* @param deviceMap - the devices that have olm sessions, as returned by
* olmlib.ensureOlmSessionsForDevices.
* @param devicesByUser - a map of user IDs to array of deviceInfo
* @param noOlmDevices - an array to fill with devices that don't have
@@ -576,23 +571,23 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* noOlmDevices is specified, then noOlmDevices will be returned.
*/
private getDevicesWithoutSessions(
devicemap: Record<string, Record<string, IOlmSessionResult>>,
devicesByUser: Record<string, DeviceInfo[]>,
deviceMap: Map<string, Map<string, IOlmSessionResult>>,
devicesByUser: Map<string, DeviceInfo[]>,
noOlmDevices: IOlmDevice[] = [],
): IOlmDevice[] {
for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) {
const sessionResults = devicemap[userId];
for (const [userId, devicesToShareWith] of devicesByUser) {
const sessionResults = deviceMap.get(userId);
for (const deviceInfo of devicesToShareWith) {
const deviceId = deviceInfo.deviceId;
const sessionResult = sessionResults[deviceId];
if (!sessionResult.sessionId) {
const sessionResult = sessionResults?.get(deviceId);
if (!sessionResult?.sessionId) {
// no session with this device, probably because there
// were no one-time keys.
noOlmDevices.push({ userId, deviceInfo });
delete sessionResults[deviceId];
sessionResults?.delete(deviceId);
// ensureOlmSessionsForUsers has already done the logging,
// so just skip it.
@@ -615,7 +610,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* @returns the blocked devices, split into chunks
*/
private splitDevices<T extends DeviceInfo | IBlockedDevice>(
devicesByUser: Record<string, Record<string, { device: T }>>,
devicesByUser: Map<string, Map<string, { device: T }>>,
): IOlmDevice<T>[][] {
const maxDevicesPerRequest = 20;
@@ -623,8 +618,8 @@ export class MegolmEncryption extends EncryptionAlgorithm {
let currentSlice: IOlmDevice<T>[] = [];
const mapSlices = [currentSlice];
for (const [userId, userDevices] of Object.entries(devicesByUser)) {
for (const deviceInfo of Object.values(userDevices)) {
for (const [userId, userDevices] of devicesByUser) {
for (const deviceInfo of userDevices.values()) {
currentSlice.push({
userId: userId,
deviceInfo: deviceInfo.device,
@@ -702,7 +697,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
userDeviceMap: IOlmDevice<IBlockedDevice>[],
payload: IPayload,
): Promise<void> {
const contentMap: Record<string, Record<string, IPayload>> = {};
const contentMap: MapWithDefault<string, Map<string, IPayload>> = new MapWithDefault(() => new Map());
for (const val of userDeviceMap) {
const userId = val.userId;
@@ -722,17 +717,14 @@ export class MegolmEncryption extends EncryptionAlgorithm {
delete message.session_id;
}
if (!contentMap[userId]) {
contentMap[userId] = {};
}
contentMap[userId][deviceId] = message;
contentMap.getOrCreate(userId).set(deviceId, message);
}
await this.baseApis.sendToDevice("m.room_key.withheld", contentMap);
// record the fact that we notified these blocked devices
for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) {
for (const [userId, userDeviceMap] of contentMap) {
for (const deviceId of userDeviceMap.keys()) {
session.markNotifiedBlockedDevice(userId, deviceId);
}
}
@@ -760,11 +752,11 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
// The chain index of the key we previously sent this device
if (obSessionInfo.sharedWithDevices[userId] === undefined) {
if (!obSessionInfo.sharedWithDevices.has(userId)) {
this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`);
return;
}
const sessionSharedData = obSessionInfo.sharedWithDevices[userId][device.deviceId];
const sessionSharedData = obSessionInfo.sharedWithDevices.get(userId)?.get(device.deviceId);
if (sessionSharedData === undefined) {
this.prefixedLogger.debug(
`megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`,
@@ -796,9 +788,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
return;
}
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, {
[userId]: [device],
});
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [device]]]));
const payload = {
type: "m.forwarded_room_key",
@@ -831,11 +821,10 @@ export class MegolmEncryption extends EncryptionAlgorithm {
payload,
);
await this.baseApis.sendToDevice("m.room.encrypted", {
[userId]: {
[device.deviceId]: encryptedContent,
},
});
await this.baseApis.sendToDevice(
"m.room.encrypted",
new Map([[userId, new Map([[device.deviceId, encryptedContent]])]]),
);
this.prefixedLogger.debug(
`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`,
);
@@ -865,7 +854,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
session: OutboundSessionInfo,
key: IOutboundGroupSessionKey,
payload: IPayload,
devicesByUser: Record<string, DeviceInfo[]>,
devicesByUser: Map<string, DeviceInfo[]>,
errorDevices: IOlmDevice[],
otkTimeout: number,
failedServers?: string[],
@@ -887,9 +876,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
session: OutboundSessionInfo,
key: IOutboundGroupSessionKey,
payload: IPayload,
devicemap: Record<string, Record<string, IOlmSessionResult>>,
deviceMap: Map<string, Map<string, IOlmSessionResult>>,
): Promise<void> {
const userDeviceMaps = this.splitDevices(devicemap);
const userDeviceMaps = this.splitDevices(deviceMap);
for (let i = 0; i < userDeviceMaps.length; i++) {
const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`;
@@ -934,19 +923,20 @@ export class MegolmEncryption extends EncryptionAlgorithm {
this.prefixedLogger.debug(
`Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`,
);
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault(
() => new Map(),
);
for (const { userId, deviceInfo } of unnotifiedFailedDevices) {
blockedMap[userId] = blockedMap[userId] || {};
// we use a similar format to what
// olmlib.ensureOlmSessionsForDevices returns, so that
// we can use the same function to split
blockedMap[userId][deviceInfo.deviceId] = {
blockedMap.getOrCreate(userId).set(deviceInfo.deviceId, {
device: {
code: "m.no_olm",
reason: WITHHELD_MESSAGES["m.no_olm"],
deviceInfo,
},
};
});
}
// send the notifications
@@ -964,7 +954,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
*/
private async notifyBlockedDevices(
session: OutboundSessionInfo,
devicesByUser: Record<string, Record<string, { device: IBlockedDevice }>>,
devicesByUser: Map<string, Map<string, { device: IBlockedDevice }>>,
): Promise<void> {
const payload: IPayload = {
room_id: this.roomId,
@@ -1154,21 +1144,17 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* devices we should shared the session with.
*/
private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void {
const unknownDevices: Record<string, Record<string, DeviceInfo>> = {};
const unknownDevices: MapWithDefault<string, Map<string, DeviceInfo>> = new MapWithDefault(() => new Map());
Object.keys(devicesInRoom).forEach((userId) => {
Object.keys(devicesInRoom[userId]).forEach((deviceId) => {
const device = devicesInRoom[userId][deviceId];
for (const [userId, userDevices] of devicesInRoom) {
for (const [deviceId, device] of userDevices) {
if (device.isUnverified() && !device.isKnown()) {
if (!unknownDevices[userId]) {
unknownDevices[userId] = {};
}
unknownDevices[userId][deviceId] = device;
unknownDevices.getOrCreate(userId).set(deviceId, device);
}
});
});
}
}
if (Object.keys(unknownDevices).length) {
if (unknownDevices.size) {
// it'd be kind to pass unknownDevices up to the user in this error
throw new UnknownDeviceError(
"This room contains unknown devices which have not been verified. " +
@@ -1186,15 +1172,15 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* devices we should shared the session with.
*/
private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void {
for (const [userId, userDevices] of Object.entries(devicesInRoom)) {
for (const [deviceId, device] of Object.entries(userDevices)) {
for (const [userId, userDevices] of devicesInRoom) {
for (const [deviceId, device] of userDevices) {
if (device.isUnverified() && !device.isKnown()) {
delete userDevices[deviceId];
userDevices.delete(deviceId);
}
}
if (Object.keys(userDevices).length === 0) {
delete devicesInRoom[userId];
if (userDevices.size === 0) {
devicesInRoom.delete(userId);
}
}
}
@@ -1219,17 +1205,17 @@ export class MegolmEncryption extends EncryptionAlgorithm {
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified?: boolean,
): Promise<[DeviceInfoMap, IBlockedMap]>;
): Promise<[DeviceInfoMap, BlockedMap]>;
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified?: boolean,
isCancelled?: () => boolean,
): Promise<null | [DeviceInfoMap, IBlockedMap]>;
): Promise<null | [DeviceInfoMap, BlockedMap]>;
private async getDevicesInRoom(
room: Room,
forceDistributeToUnverified = false,
isCancelled?: () => boolean,
): Promise<null | [DeviceInfoMap, IBlockedMap]> {
): Promise<null | [DeviceInfoMap, BlockedMap]> {
const members = await room.getEncryptionTargetMembers();
this.prefixedLogger.debug(
`Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`,
@@ -1254,24 +1240,15 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// using all the device_lists changes and left fields.
// See https://github.com/vector-im/element-web/issues/2305 for details.
const devices = await this.crypto.downloadKeys(roomMembers, false);
const blocked: IBlockedMap = {};
if (isCancelled?.() === true) {
return null;
}
const blocked = new MapWithDefault<string, Map<string, IBlockedDevice>>(() => new Map());
// remove any blocked devices
for (const userId in devices) {
if (!devices.hasOwnProperty(userId)) {
continue;
}
const userDevices = devices[userId];
for (const deviceId in userDevices) {
if (!userDevices.hasOwnProperty(deviceId)) {
continue;
}
for (const [userId, userDevices] of devices) {
for (const [deviceId, userDevice] of userDevices) {
// Yield prior to checking each device so that we don't block
// updating/rendering for too long.
// See https://github.com/vector-im/element-web/issues/21612
@@ -1280,19 +1257,17 @@ export class MegolmEncryption extends EncryptionAlgorithm {
const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId);
if (
userDevices[deviceId].isBlocked() ||
userDevice.isBlocked() ||
(!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified)
) {
if (!blocked[userId]) {
blocked[userId] = {};
}
const isBlocked = userDevices[deviceId].isBlocked();
blocked[userId][deviceId] = {
const blockedDevices = blocked.getOrCreate(userId);
const isBlocked = userDevice.isBlocked();
blockedDevices.set(deviceId, {
code: isBlocked ? "m.blacklisted" : "m.unverified",
reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"],
deviceInfo: userDevices[deviceId],
};
delete userDevices[deviceId];
deviceInfo: userDevice,
});
userDevices.delete(deviceId);
}
}
}
@@ -1923,7 +1898,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false);
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[sender, [device]]]), false);
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key!,
@@ -1942,11 +1917,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
await this.baseApis.sendToDevice("m.room.encrypted", {
[sender]: {
[device.deviceId]: encryptedContent,
},
});
await this.baseApis.sendToDevice(
"m.room.encrypted",
new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]),
);
}
public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
@@ -1969,12 +1943,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// XXX: switch this to use encryptAndSendToDevices()?
this.olmlib
.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, {
[userId]: [deviceInfo],
})
.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]]))
.then((devicemap) => {
const olmSessionResult = devicemap[userId][deviceId];
if (!olmSessionResult.sessionId) {
const olmSessionResult = devicemap.get(userId)?.get(deviceId);
if (!olmSessionResult?.sessionId) {
// no session with this device, probably because there
// were no one-time keys.
//
@@ -2015,14 +1987,11 @@ export class MegolmDecryption extends DecryptionAlgorithm {
payload!,
)
.then(() => {
const contentMap = {
[userId]: {
[deviceId]: encryptedContent,
},
};
// TODO: retries
return this.baseApis.sendToDevice("m.room.encrypted", contentMap);
return this.baseApis.sendToDevice(
"m.room.encrypted",
new Map([[userId, new Map([[deviceId, encryptedContent]])]]),
);
});
});
}
@@ -2162,12 +2131,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
return !this.pendingEvents.has(senderKey);
}
public async sendSharedHistoryInboundSessions(devicesByUser: Record<string, DeviceInfo[]>): Promise<void> {
public async sendSharedHistoryInboundSessions(devicesByUser: Map<string, DeviceInfo[]>): Promise<void> {
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
this.prefixedLogger.log(
`Sharing history in with users ${Object.keys(devicesByUser)}`,
`Sharing history in with users ${Array.from(devicesByUser.keys())}`,
sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
);
for (const [senderKey, sessionId] of sharedHistorySessions) {
@@ -2175,9 +2144,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// FIXME: use encryptAndSendToDevices() rather than duplicating it here.
const promises: Promise<unknown>[] = [];
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
for (const [userId, devices] of Object.entries(devicesByUser)) {
contentMap[userId] = {};
const contentMap: Map<string, Map<string, IEncryptedContent>> = new Map();
for (const [userId, devices] of devicesByUser) {
const deviceMessages = new Map();
contentMap.set(userId, deviceMessages);
for (const deviceInfo of devices) {
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
@@ -2185,7 +2155,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
ciphertext: {},
[ToDeviceMessageId]: uuidv4(),
};
contentMap[userId][deviceInfo.deviceId] = encryptedContent;
deviceMessages.set(deviceInfo.deviceId, encryptedContent);
promises.push(
olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
@@ -2205,22 +2175,22 @@ export class MegolmDecryption extends DecryptionAlgorithm {
// in which case it will have just not added anything to the ciphertext object.
// There's no point sending messages to devices if we couldn't encrypt to them,
// since that's effectively a blank message.
for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) {
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
for (const [userId, deviceMessages] of contentMap) {
for (const [deviceId, content] of deviceMessages) {
if (!hasCiphertext(content)) {
this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
delete contentMap[userId][deviceId];
deviceMessages.delete(deviceId);
}
}
// No devices left for that user? Strip that too.
if (Object.keys(contentMap[userId]).length === 0) {
if (deviceMessages.size === 0) {
this.prefixedLogger.log("Pruned all devices for user " + userId);
delete contentMap[userId];
contentMap.delete(userId);
}
}
// Is there anything left?
if (Object.keys(contentMap).length === 0) {
if (contentMap.size === 0) {
this.prefixedLogger.log("No users left to send to: aborting");
return;
}
+9 -25
View File
@@ -16,6 +16,14 @@ limitations under the License.
import { DeviceInfo } from "./deviceinfo";
import { IKeyBackupInfo } from "./keybackup";
import type { AddSecretStorageKeyOpts } from "../secret-storage";
/* re-exports for backwards compatibility. */
export type {
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
PassphraseInfo as IPassphraseInfo,
SecretStorageKeyDescription as ISecretStorageKeyInfo,
} from "../secret-storage";
// TODO: Merge this with crypto.js once converted
@@ -58,7 +66,7 @@ export interface IEncryptedEventInfo {
}
export interface IRecoveryKey {
keyInfo?: IAddSecretStorageKeyOpts;
keyInfo?: AddSecretStorageKeyOpts;
privateKey: Uint8Array;
encodedPrivateKey?: string;
}
@@ -98,30 +106,6 @@ export interface ICreateSecretStorageOpts {
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
export interface ISecretStorageKeyInfo {
name: string;
algorithm: string;
// technically the below are specific to AES keys. If we ever introduce another type,
// we can split into separate interfaces.
iv: string;
mac: string;
passphrase: IPassphraseInfo;
}
export interface IPassphraseInfo {
algorithm: "m.pbkdf2";
iterations: number;
salt: string;
bits?: number;
}
export interface IAddSecretStorageKeyOpts {
pubkey?: string;
passphrase?: IPassphraseInfo;
name?: string;
key?: Uint8Array;
}
export interface IImportOpts {
stage: string; // TODO: Enum
successes: number;
+4 -6
View File
@@ -25,7 +25,7 @@ import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
import { DeviceInfo } from "./deviceinfo";
import { DeviceTrustLevel } from "./CrossSigning";
import { keyFromPassphrase } from "./key_passphrase";
import { sleep } from "../utils";
import { safeSet, sleep } from "../utils";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { encodeRecoveryKey } from "./recoverykey";
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
@@ -498,9 +498,7 @@ export class BackupManager {
const rooms: IKeyBackup["rooms"] = {};
for (const session of sessions) {
const roomId = session.sessionData!.room_id;
if (rooms[roomId] === undefined) {
rooms[roomId] = { sessions: {} };
}
safeSet(rooms, roomId, rooms[roomId] || { sessions: {} });
const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession(
session.senderKey,
@@ -517,12 +515,12 @@ export class BackupManager {
undefined;
const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified();
rooms[roomId]["sessions"][session.sessionId] = {
safeSet(rooms[roomId]["sessions"], session.sessionId, {
first_message_index: sessionData.first_known_index,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: await this.algorithm!.encryptSession(sessionData),
};
});
}
await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo!.version, { rooms });
+2 -2
View File
@@ -21,13 +21,13 @@ import { decodeBase64, encodeBase64 } from "./olmlib";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
import { logger } from "../logger";
import { ISecretStorageKeyInfo } from "./api";
import { Crypto } from "./index";
import { Method } from "../http-api";
import { SecretStorageKeyDescription } from "../secret-storage";
export interface IDehydratedDevice {
device_id: string; // eslint-disable-line camelcase
device_data: ISecretStorageKeyInfo & {
device_data: SecretStorageKeyDescription & {
// eslint-disable-line camelcase
algorithm: string;
account: string; // pickle
+134 -102
View File
@@ -34,22 +34,8 @@ import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms";
import * as algorithms from "./algorithms";
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
import { EncryptionSetupBuilder } from "./EncryptionSetup";
import {
IAccountDataClient,
ISecretRequest,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorage,
SecretStorageKeyObject,
SecretStorageKeyTuple,
} from "./SecretStorage";
import {
IAddSecretStorageKeyOpts,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
IRecoveryKey,
ISecretStorageKeyInfo,
} from "./api";
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
import { ICreateSecretStorageOpts, IEncryptedEventInfo, IImportRoomKeysOpts, IRecoveryKey } from "./api";
import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { VerificationBase } from "./verification/Base";
@@ -85,11 +71,23 @@ 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 {
AccountDataClient,
AddSecretStorageKeyOpts,
SecretStorageKeyDescription,
SecretStorageKeyObject,
SecretStorageKeyTuple,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorageCallbacks,
ServerSideSecretStorageImpl,
} from "../secret-storage";
import { ISecretRequest } from "./SecretSharing";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -136,15 +134,11 @@ export interface IBootstrapCrossSigningOpts {
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, ISecretStorageKeyInfo> },
name: string,
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array) => void;
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
onSecretRequested?: (
userId: string,
deviceId: string,
@@ -152,7 +146,10 @@ export interface ICryptoCallbacks {
secretName: string,
deviceTrust: DeviceTrustLevel,
) => Promise<string | undefined>;
getDehydrationKey?: (keyInfo: ISecretStorageKeyInfo, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>;
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
@@ -248,8 +245,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
@@ -352,7 +349,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>;
@@ -399,7 +396,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// deviceId: 1234567890000,
// },
// }
private lastNewSessionForced: Record<string, Record<string, number>> = {};
// Map: user Id → device Id → timestamp
private lastNewSessionForced: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault(
() => new MapWithDefault(() => 0),
);
// This flag will be unset whilst the client processes a sync response
// so that we don't start requesting keys until we've actually finished
@@ -476,15 +476,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);
@@ -522,7 +522,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.
@@ -827,10 +827,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");
@@ -893,17 +892,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;
}
@@ -919,7 +917,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return keyId;
};
const ensureCanCheckPassphrase = async (keyId: string, keyInfo: ISecretStorageKeyInfo): Promise<void> => {
const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise<void> => {
if (!keyInfo.mac) {
const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.(
{ keys: { [keyId]: keyInfo } },
@@ -952,7 +950,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;
@@ -977,7 +975,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
@@ -988,7 +986,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: ???
@@ -1102,31 +1100,49 @@ 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);
}
public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
*/
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
return this.secretStorage.isStored(name);
}
@@ -1137,15 +1153,24 @@ 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);
}
public checkSecretStorageKey(key: Uint8Array, info: ISecretStorageKeyInfo): Promise<boolean> {
/**
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
*/
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
return this.secretStorage.checkKey(key, info);
}
@@ -1780,8 +1805,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;
@@ -1790,8 +1814,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;
@@ -1816,24 +1839,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;
}
@@ -1969,7 +1974,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();
@@ -2488,13 +2493,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* This should not normally be necessary.
*/
public forceDiscardSession(roomId: string): void {
public forceDiscardSession(roomId: string): Promise<void> {
const alg = this.roomEncryptors.get(roomId);
if (alg === undefined) throw new Error("Room not encrypted");
if (alg.forceDiscardSession === undefined) {
throw new Error("Room encryption algorithm doesn't support session discarding");
}
alg.forceDiscardSession();
return Promise.resolve();
}
/**
@@ -2685,16 +2691,18 @@ 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[],
force?: boolean,
): Promise<Record<string, Record<string, olmlib.IOlmSessionResult>>> {
const devicesByUser: Record<string, DeviceInfo[]> = {};
): Promise<Map<string, Map<string, olmlib.IOlmSessionResult>>> {
// map user Id → DeviceInfo[]
const devicesByUser: Map<string, DeviceInfo[]> = new Map();
for (const userId of users) {
devicesByUser[userId] = [];
const userDevices: DeviceInfo[] = [];
devicesByUser.set(userId, userDevices);
const devices = this.getStoredDevicesForUser(userId) || [];
for (const deviceInfo of devices) {
@@ -2708,7 +2716,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
continue;
}
devicesByUser[userId].push(deviceInfo);
userDevices.push(deviceInfo);
}
}
@@ -2907,21 +2915,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.
@@ -2930,7 +2929,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);
}
/**
@@ -3146,7 +3145,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
payload: encryptedContent,
});
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [userId]: [deviceInfo] });
await olmlib.ensureOlmSessionsForDevices(
this.olmDevice,
this.baseApis,
new Map([[userId, [deviceInfo]]]),
);
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this.userId,
@@ -3207,6 +3210,36 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
/**
* 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 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();
}
private onToDeviceEvent = (event: MatrixEvent): void => {
try {
logger.log(
@@ -3448,8 +3481,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// check when we last forced a new session with this device: if we've already done so
// recently, don't do it again.
this.lastNewSessionForced[sender] = this.lastNewSessionForced[sender] || {};
const lastNewSessionForced = this.lastNewSessionForced[sender][deviceKey] || 0;
const lastNewSessionDevices = this.lastNewSessionForced.getOrCreate(sender);
const lastNewSessionForced = lastNewSessionDevices.getOrCreate(deviceKey);
if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) {
logger.debug(
"New session already forced with device " +
@@ -3482,11 +3515,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return;
}
}
const devicesByUser: Record<string, DeviceInfo[]> = {};
devicesByUser[sender] = [device];
const devicesByUser = new Map([[sender, [device]]]);
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true);
this.lastNewSessionForced[sender][deviceKey] = Date.now();
lastNewSessionDevices.set(deviceKey, Date.now());
// Now send a blank message on that session so the other side knows about it.
// (The keyshare request is sent in the clear so that won't do)
@@ -3513,11 +3545,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true);
retryDecryption();
await this.baseApis.sendToDevice("m.room.encrypted", {
[sender]: {
[device.deviceId]: encryptedContent,
},
});
await this.baseApis.sendToDevice(
"m.room.encrypted",
new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]),
);
// Most of the time this probably won't be necessary since we'll have queued up a key request when
// we failed to decrypt the message and will be waiting a bit for the key to arrive before sending
@@ -3761,7 +3792,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;
@@ -3824,15 +3855,16 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param obj - Object to which we will add a 'signatures' property
*/
public async signObject<T extends ISignableObject & object>(obj: T): Promise<void> {
const sigs = obj.signatures || {};
const sigs = new Map(Object.entries(obj.signatures || {}));
const unsigned = obj.unsigned;
delete obj.signatures;
delete obj.unsigned;
sigs[this.userId] = sigs[this.userId] || {};
sigs[this.userId]["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj));
obj.signatures = sigs;
const userSignatures = sigs.get(this.userId) || {};
sigs.set(this.userId, userSignatures);
userSignatures["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj));
obj.signatures = recursiveMapToObject(sigs);
if (unsigned !== undefined) obj.unsigned = unsigned;
}
}
+38 -37
View File
@@ -30,6 +30,7 @@ import { ISignatures } from "../@types/signed";
import { MatrixEvent } from "../models/event";
import { EventType } from "../@types/event";
import { IMessage } from "./algorithms/olm";
import { MapWithDefault } from "../utils";
enum Algorithm {
Olm = "m.olm.v1.curve25519-aes-sha2",
@@ -154,9 +155,11 @@ export async function getExistingOlmSessions(
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Record<string, DeviceInfo[]>,
): Promise<[Record<string, DeviceInfo[]>, Record<string, Record<string, IExistingOlmSession>>]> {
const devicesWithoutSession: { [userId: string]: DeviceInfo[] } = {};
const sessions: { [userId: string]: { [deviceId: string]: IExistingOlmSession } } = {};
): Promise<[Map<string, DeviceInfo[]>, Map<string, Map<string, IExistingOlmSession>>]> {
// map user Id → DeviceInfo[]
const devicesWithoutSession: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []);
// map user Id → device Id → IExistingOlmSession
const sessions: MapWithDefault<string, Map<string, IExistingOlmSession>> = new MapWithDefault(() => new Map());
const promises: Promise<void>[] = [];
@@ -168,14 +171,12 @@ export async function getExistingOlmSessions(
(async (): Promise<void> => {
const sessionId = await olmDevice.getSessionIdForDevice(key, true);
if (sessionId === null) {
devicesWithoutSession[userId] = devicesWithoutSession[userId] || [];
devicesWithoutSession[userId].push(deviceInfo);
devicesWithoutSession.getOrCreate(userId).push(deviceInfo);
} else {
sessions[userId] = sessions[userId] || {};
sessions[userId][deviceId] = {
sessions.getOrCreate(userId).set(deviceId, {
device: deviceInfo,
sessionId: sessionId,
};
});
}
})(),
);
@@ -210,24 +211,26 @@ export async function getExistingOlmSessions(
export async function ensureOlmSessionsForDevices(
olmDevice: OlmDevice,
baseApis: MatrixClient,
devicesByUser: Record<string, DeviceInfo[]>,
devicesByUser: Map<string, DeviceInfo[]>,
force = false,
otkTimeout?: number,
failedServers?: string[],
log = logger,
): Promise<Record<string, Record<string, IOlmSessionResult>>> {
): Promise<Map<string, Map<string, IOlmSessionResult>>> {
const devicesWithoutSession: [string, string][] = [
// [userId, deviceId], ...
];
const result: { [userId: string]: { [deviceId: string]: IExistingOlmSession } } = {};
const resolveSession: Record<string, (sessionId?: string) => void> = {};
// map user Id → device Id → IExistingOlmSession
const result: Map<string, Map<string, IExistingOlmSession>> = new Map();
// map device key → resolve session fn
const resolveSession: Map<string, (sessionId?: string) => void> = new Map();
// Mark all sessions this task intends to update as in progress. It is
// important to do this for all devices this task cares about in a single
// synchronous operation, as otherwise it is possible to have deadlocks
// where multiple tasks wait indefinitely on another task to update some set
// of common devices.
for (const [, devices] of Object.entries(devicesByUser)) {
for (const devices of devicesByUser.values()) {
for (const deviceInfo of devices) {
const key = deviceInfo.getIdentityKey();
@@ -242,17 +245,19 @@ export async function ensureOlmSessionsForDevices(
// conditions. If we find that we already have a session, then
// we'll resolve
olmDevice.sessionsInProgress[key] = new Promise((resolve) => {
resolveSession[key] = (v: any): void => {
resolveSession.set(key, (v: any): void => {
delete olmDevice.sessionsInProgress[key];
resolve(v);
};
});
});
}
}
}
for (const [userId, devices] of Object.entries(devicesByUser)) {
result[userId] = {};
for (const [userId, devices] of devicesByUser) {
const resultDevices = new Map();
result.set(userId, resultDevices);
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey();
@@ -268,20 +273,21 @@ export async function ensureOlmSessionsForDevices(
log.info("Attempted to start session with ourself! Ignoring");
// We must fill in the section in the return value though, as callers
// expect it to be there.
result[userId][deviceId] = {
resultDevices.set(deviceId, {
device: deviceInfo,
sessionId: null,
};
});
continue;
}
const forWhom = `for ${key} (${userId}:${deviceId})`;
const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession[key], log);
if (sessionId !== null && resolveSession[key]) {
const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log);
const resolveSessionFn = resolveSession.get(key);
if (sessionId !== null && resolveSessionFn) {
// we found a session, but we had marked the session as
// in-progress, so resolve it now, which will unmark it and
// unblock anything that was waiting
resolveSession[key]();
resolveSessionFn();
}
if (sessionId === null || force) {
if (force) {
@@ -291,10 +297,10 @@ export async function ensureOlmSessionsForDevices(
}
devicesWithoutSession.push([userId, deviceId]);
}
result[userId][deviceId] = {
resultDevices.set(deviceId, {
device: deviceInfo,
sessionId: sessionId,
};
});
}
}
@@ -310,7 +316,7 @@ export async function ensureOlmSessionsForDevices(
res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout);
log.debug(`Claimed ${taskDetail}`);
} catch (e) {
for (const resolver of Object.values(resolveSession)) {
for (const resolver of resolveSession.values()) {
resolver();
}
log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
@@ -323,7 +329,7 @@ export async function ensureOlmSessionsForDevices(
const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]);
const promises: Promise<void>[] = [];
for (const [userId, devices] of Object.entries(devicesByUser)) {
for (const [userId, devices] of devicesByUser) {
const userRes = otkResult[userId] || {};
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
@@ -336,7 +342,7 @@ export async function ensureOlmSessionsForDevices(
continue;
}
if (result[userId][deviceId].sessionId && !force) {
if (result.get(userId)?.get(deviceId)?.sessionId && !force) {
// we already have a result for this device
continue;
}
@@ -351,24 +357,19 @@ export async function ensureOlmSessionsForDevices(
if (!oneTimeKey) {
log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`);
if (resolveSession[key]) {
resolveSession[key]();
}
resolveSession.get(key)?.();
continue;
}
promises.push(
_verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then(
(sid) => {
if (resolveSession[key]) {
resolveSession[key](sid ?? undefined);
}
result[userId][deviceId].sessionId = sid;
resolveSession.get(key)?.(sid ?? undefined);
const deviceInfo = result.get(userId)?.get(deviceId);
if (deviceInfo) deviceInfo.sessionId = sid;
},
(e) => {
if (resolveSession[key]) {
resolveSession[key]();
}
resolveSession.get(key)?.();
throw e;
},
),
+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;
}
@@ -21,6 +21,7 @@ import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { ICrossSigningKey } from "../../client";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. Partial localStorage backed storage for e2e.
@@ -178,11 +179,11 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
notifiedErrorDevices[userId][deviceInfo.deviceId] = true;
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
notifiedErrorDevices[userId] = { [deviceInfo.deviceId]: true };
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
+4 -3
View File
@@ -33,6 +33,7 @@ import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. in-memory storage for e2e.
@@ -337,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> {
@@ -375,11 +376,11 @@ export class MemoryCryptoStore implements CryptoStore {
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
notifiedErrorDevices[userId][deviceInfo.deviceId] = true;
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
notifiedErrorDevices[userId] = { [deviceInfo.deviceId]: true };
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
@@ -269,12 +269,12 @@ export class ToDeviceChannel implements IVerificationChannel {
private async sendToDevices(type: string, content: Record<string, any>, devices: string[]): Promise<void> {
if (devices.length) {
const msgMap: Record<string, Record<string, any>> = {};
const deviceMessages: Map<string, Record<string, any>> = new Map();
for (const deviceId of devices) {
msgMap[deviceId] = content;
deviceMessages.set(deviceId, content);
}
await this.client.sendToDevice(type, { [this.userId]: msgMap });
await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]]));
}
}
+13 -15
View File
@@ -29,15 +29,16 @@ import { IEvent, IContent, EventStatus } from "./models/event";
import { ISendEventResponse } from "./@types/requests";
import { EventType } from "./@types/event";
import { logger } from "./logger";
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts } from "./client";
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
import { SyncApi, SyncState } from "./sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { MatrixEvent } from "./models/event";
import { User } from "./models/user";
import { Room } from "./models/room";
import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
import { DeviceInfo } from "./crypto/deviceinfo";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { MapWithDefault, recursiveMapToObject } from "./utils";
interface IStateEventRequest {
eventType: string;
@@ -234,35 +235,32 @@ export class RoomWidgetClient extends MatrixClient {
return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
}
public async sendToDevice(
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
): Promise<{}> {
await this.widgetApi.sendToDevice(eventType, false, contentMap);
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<{}> {
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
return {};
}
public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
for (const { userId, deviceId, payload } of batch) {
if (!contentMap[userId]) contentMap[userId] = {};
contentMap[userId][deviceId] = payload;
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi.sendToDevice(eventType, false, contentMap);
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
}
public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
for (const {
userId,
deviceInfo: { deviceId },
} of userDeviceInfoArr) {
if (!contentMap[userId]) contentMap[userId] = {};
contentMap[userId][deviceId] = payload;
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, contentMap);
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
}
// Overridden since we get TURN servers automatically over the widget API,
+3
View File
@@ -25,6 +25,9 @@ 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",
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -97,7 +97,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
+5 -1
View File
@@ -47,6 +47,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";
@@ -55,18 +56,21 @@ export * from "./@types/requests";
export * from "./@types/search";
export * from "./models/room-summary";
export * as ContentHelpers from "./content-helpers";
export * as SecretStorage from "./secret-storage";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
export { createNewMatrixCall } from "./webrtc/call";
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 { 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;
+9 -7
View File
@@ -48,15 +48,17 @@ export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier =>
// https://github.com/matrix-org/matrix-spec-proposals/pull/3672
export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> {
public readonly roomId: string;
private _beaconInfo?: BeaconInfoState;
// beaconInfo is assigned by setBeaconInfo in the constructor
// ! to make tsc believe it is definitely assigned
private _beaconInfo!: BeaconInfoState;
private _isLive?: boolean;
private livenessWatchTimeout?: ReturnType<typeof setTimeout>;
private _latestLocationEvent?: MatrixEvent;
public constructor(private rootEvent: MatrixEvent) {
super();
this.setBeaconInfo(this.rootEvent);
this.roomId = this.rootEvent.getRoomId()!;
this.setBeaconInfo(this.rootEvent);
}
public get isLive(): boolean {
@@ -79,7 +81,7 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this.rootEvent.getType();
}
public get beaconInfo(): BeaconInfoState | undefined {
public get beaconInfo(): BeaconInfoState {
return this._beaconInfo;
}
@@ -158,9 +160,9 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
const { timestamp } = parsed;
return (
this._beaconInfo!.timestamp &&
this._beaconInfo.timestamp &&
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo!.timestamp, this._beaconInfo!.timeout, timestamp) &&
isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
// ignore positions older than our current latest location
(!this.latestLocationState || timestamp > this.latestLocationState.timestamp!)
);
@@ -196,9 +198,9 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
? this.beaconInfo.timestamp! - 360000 /* 6min */
: this.beaconInfo.timestamp;
this._isLive =
!!this._beaconInfo?.live &&
!!this._beaconInfo.live &&
!!startTimestamp &&
isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
isTimestampInDuration(startTimestamp, this._beaconInfo.timeout, Date.now());
if (prevLiveness !== this.isLive) {
this.emit(BeaconEvent.LivenessChange, this.isLive, this);
+44 -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";
@@ -48,6 +49,8 @@ export interface IContent {
"avatar_url"?: string;
"displayname"?: string;
"m.relates_to"?: IEventRelation;
"org.matrix.msc3952.mentions"?: IMentions;
}
type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>;
@@ -114,6 +117,16 @@ export interface IEventRelation {
"key"?: string;
}
export interface IMentions {
user_ids?: string[];
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.
@@ -213,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;
@@ -881,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);
@@ -1234,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);
};

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