Compare commits

...

86 Commits

Author SHA1 Message Date
ElementRobot 430e6cae94 v30.0.1 2023-11-13 09:44:06 +00:00
ElementRobot e01a1d533c Prepare changelog for v30.0.1 2023-11-13 09:44:04 +00:00
ElementRobot 46a6a76a41 [Backport staging] Ensure setUserCreator is called when a store is assigned (#3876)
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2023-11-13 09:11:27 +00:00
ElementRobot fe3f969698 v30.0.0 2023-11-07 15:11:47 +00:00
ElementRobot 96c6c99644 Prepare changelog for v30.0.0 2023-11-07 15:11:45 +00:00
ElementRobot 55230dd0ea [Backport staging] Revert code moving deleted messages to main timeline (#3859)
Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2023-11-07 14:31:37 +00:00
ElementRobot 5707b48fd2 v30.0.0-rc.1 2023-10-31 14:40:50 +00:00
ElementRobot 8ac918c10f Prepare changelog for v30.0.0-rc.1 2023-10-31 14:40:48 +00:00
Florian Duros 1cd8bed705 Element-R: Add the git sha of the binding crate to CryptoApi#getVersion (#3838)
* Update `@matrix-org/matrix-sdk-crypto-wasm` to `v2.2.0`

* Add the git sha of the binding crate to `CryptoApi#getVersions`
2023-10-31 09:15:14 +00:00
Hubert Chathi e0dacf7529 use olm from default npm registry, since it's there now (#3837)
and bump to latest version
2023-10-28 10:23:03 +00:00
Dariusz Niemczyk 29d9bdac61 feat: Add autoformat and lint for ts/tsx files (#3835)
* feat: Add autoformat and lint for ts/tsx files

* Update .lintstagedrc

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

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-10-27 14:38:18 +00:00
R Midhun Suresh 88d066a10c Fix reemitter not being correctly wired on user objects created in storage classes (#3796)
* Fix issue

* Fix jest test

* Fix even more jest failures

* Fix formatting

* Add a test

* Write test for older code

* Fix lint

* Rename method

* Make ctor deprecated
2023-10-27 07:00:13 +00:00
Florian Duros ce7b7bf44f Element-R: Wire up globalBlacklistUnverifiedDevices field to rust crypto encryption settings (#3790)
* Wire up `globalBlacklistUnverifiedDevices` rust crypto encrypted settings

* Improve test comment

* Update comments

* Review changes

* Fix lint due to merge
2023-10-26 13:57:37 +00:00
Richard van der Hoff 07a9eb3c96 Element-R: reduce log spam when checking server key backup (#3826)
* Element-R: reduce log spam when checking server key backup

Fixes a lot of spam in the logs about "uncaught in promise: No room_keys
found".

* Improve integ tests for backup query after UTD

* Yield in the backup decryption loop

* Fix another broken test
2023-10-26 11:10:04 +00:00
Florian Duros f8f22a3edd Element-R: Wire up room rotation (#3807)
* Wire up rotation

* Wire up algorithm

* Add encryption settings test

* Update comments
2023-10-25 15:12:04 +00:00
Richard van der Hoff 084beaa947 Fix up comments on globalErrorOnUnknownDevices (#3834)
The current deprecation notice advises you to use a method which does something
completely different.

Fixing this "properly" is slightly challenging because we don't want to support
setting it to `true` in Rust Crypto; yet I don't really want to change the
default for legacy crypto.

Let's just document the behaviour for now.
2023-10-25 14:32:16 +00:00
Florian Duros 73a87652fe Element-R: Add current version of the rust-sdk and vodozemac (#3825)
* Add current version of the rust-sdk and vodozemac

* Return OlmVersion in `CryptoApi#getVersion` for old crypto

* Add `Olm` prefix

* Fix documentation

* Review changes
2023-10-25 13:12:15 +00:00
Florian Duros 4a4b454f27 Element-R: Wire up room history visibility (#3805)
* Wire up history visibility in `RoomEncryptor.ts`

* Add more tests to history visibility conversion

* Factorize `expectSendMessage` and `expectSendMegolmMessage`

* Use correct import

* Fix overwriteRoutes

* Update comments
2023-10-25 11:49:03 +00:00
Richard van der Hoff 6f82f08c7b Element-R: silence log errors when viewing a pending event (#3824)
* Element-R: silence log errors when viewing a pending event

Fixes the second half of vector-im/element-web#26272

* Update spec/integ/crypto/crypto.spec.ts
2023-10-25 09:11:40 +00:00
Dominik Henneke c41949de15 Don't emit a closed event if the indexeddb is closed by Element (#3832)
Signed-off-by: Dominik Henneke <dominik.henneke@nordeck.net>
2023-10-25 08:14:12 +00:00
David Baker f941fd896e Change latest node ver to '*' (#3831)
* Change latest node ver to '*'

This uses the latest cached version rather than fetching the latest released version so we don't reply on (and hammer) node's download servers for the very latest version before the actions runners get updated. We'll still stay current, just not quite so aggressively current.

* Fix artifact uploading hopefully

* Hopefully make job name 'node latest'
2023-10-24 14:45:03 +00:00
ElementRobot d750e33ec9 Resetting package fields for development 2023-10-24 15:27:12 +01:00
ElementRobot a370442328 Merge branch 'master' into develop 2023-10-24 15:27:09 +01:00
ElementRobot bddf2b9682 v29.1.0 2023-10-24 15:27:03 +01:00
ElementRobot 74a2e694c3 Prepare changelog for v29.1.0 2023-10-24 15:27:01 +01:00
Michael Telatynski 748d03ba11 Delete .github/workflows/upgrade_dependencies.yml 2023-10-24 00:48:22 +01:00
Richard van der Hoff 2f3f0b340e Bump matrix-sdk-crypto-wasm to 2.1.1 (#3823)
... for recent changes including a fix to a panic
2023-10-23 12:42:50 +01:00
Richard van der Hoff 12e479a93e Element-R: silence log errors when viewing a decryption failure (#3821) 2023-10-23 10:16:42 +00:00
Richard van der Hoff 6e2ac03f7e Reduce log spam from rust crypto (#3819)
* turn the log level down to DEBUG
* don't pointlessly log every call to `outgoingRequests`
2023-10-20 21:43:28 +00:00
David Baker 6359e10bcf Merge pull request #3820 from matrix-org/dbkr/export_base64
Export base64 utils
2023-10-20 18:18:08 +01:00
David Baker b3a2b8b8c4 Export base64 utils
We use these from react-sdk (from crypto, when they were there,
but I just moved them and inadvertantly broke react-sdk).
2023-10-20 17:47:57 +01:00
Valere 30a9119e31 Bump wasm bindings version to 2.1.0 (#3811) 2023-10-20 17:44:55 +01:00
David Baker 7a52dba86c Merge pull request #3818 from matrix-org/dbkr/all_your_base64
Refactor & make base64 functions browser-safe
2023-10-20 16:47:29 +01:00
David Baker d6177cdfc9 Another one appeared 2023-10-20 16:23:58 +01:00
David Baker c4f3fd3289 Remove another crypto mention. None of this is crypto specific. 2023-10-20 16:11:48 +01:00
David Baker 31f38550e3 Refactor & make base64 functions browser-safe
We had two identical sets of base64 functions in the js-sdk, both
using Buffer which isn't really available in the browser unless you're
using an old webpack (ie. what element-web uses). This PR:

 * Takes the crypto base64 file and moves it out of crypto (because
   we use base64 for much more than just crypto)
 * Makes them work in a browser without the Buffer global
 * Removes the other base64 functions
 * Changes everything to use the new common ones
 * Adds a comment explaining why the function is kinda ugly and how
   soul destroyingly awful the JS ecosystem is.
 * Runs the tests with both impls
 * Changes the test to not just test the decoder against the encoder
 * Adds explicit support & tests for (decoding) base64Url (I'll add an
   encode method later, no need for that to go in this PR too).
2023-10-20 16:00:55 +01:00
Andy Balaam 0643f38592 Don't remove thread info from a thread root when it is redacted (#3814)
* Don't remove thread info from a thread root when it is redacted

* Move the redaction event to main at the same time we move redacted

Since the redacted event is moving to the main timeline, the redaction
belongs there too, since its relationship to the redacted event is the
only thing making it part of the thread.
2023-10-20 14:45:34 +00:00
Richard van der Hoff c0264954ed Reduce some log spam from MatrixRTCSession.callMembershipsForRoom (#3812)
This gets logged very frequently - including once for each room at startup -
and it's filling the logs.
2023-10-20 09:49:20 +00:00
Richard van der Hoff 7501e28dec Element-R: log when we send to-device messages (#3810)
* Log when we send to-device messages

* lint

* fix test
2023-10-19 12:58:49 +00:00
Valere febc4c9ad6 Handle backup secret gossip (#3778)
* Handle backup secret gossip

* use getSecretsFromInbox

* add gossip test

* use delete secret API

* fix logger

* better comment and cleaning

* free the pkSigning

* fix typo

* add missing mocks

* improve coverage

* better var name

* quick refactoring

* add more tests

* Review, format and comments

* refactor move more logic to backup.ts

* poll secret inbox

* missing mock

* Update src/rust-crypto/rust-crypto.ts

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

* Update src/rust-crypto/rust-crypto.ts

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

* Update src/rust-crypto/rust-crypto.ts

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

* Update src/rust-crypto/backup.ts

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

* Update src/rust-crypto/rust-crypto.ts

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

* code review

* fix comment

* remove comment

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

* quick factorise

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-19 12:39:16 +00:00
Andy Balaam 6b1d53cc14 Move events related to a redacted event into the main timeline (#3800)
* Move redaction event tests into their own describe block

* Factor out utils in redaction tests

* Factor out the code for moving an event to the main timeline

* Move all related messages into main timeline on redaction
2023-10-19 07:58:46 +00:00
renovate[bot] 04fcd5880b Update all non-major dependencies (#3776)
* Update all non-major dependencies

* Remove `name wrap-ansi-cjs`

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

* Remove `name string-width-cjs`

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

* Hold jest-sonar-reporter back

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-10-19 07:31:15 +00:00
Kerry 4bcea2cead OIDC: document OidcError use (#3808)
* comments

* Update src/oidc/authorize.ts

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

* Update src/oidc/register.ts

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

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-18 21:01:56 +00:00
renovate[bot] 6468d79458 Update typedoc (#3740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-18 11:49:04 +00:00
renovate[bot] a871376350 Update dependency fake-indexeddb to v5 (#3803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-18 08:12:53 +00:00
Michael Telatynski 6beb693616 Update cypress.yml 2023-10-18 09:11:40 +01:00
Andy Balaam 11661bbc8d Merge pull request #3798 from matrix-org/andybalaam/move-redacted-message-to-main
Move redacted messages out of any thread, into main timeline.
2023-10-18 08:00:29 +01:00
renovate[bot] 2d57f28d5a Update matrix-org/matrix-react-sdk digest to 94ca061 (#3774)
* Update matrix-org/matrix-react-sdk digest to 94ca061

* Update cypress.yml

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-10-17 17:40:07 +00:00
dependabot[bot] c52f857599 Bump @babel/traverse from 7.22.17 to 7.23.2 (#3806)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.17 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-17 17:05:31 +00:00
renovate[bot] 5d016c1e4f Update tspascoal/get-user-teams-membership action to v3 (#3804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-17 16:50:40 +00:00
Richard van der Hoff 9f04c0555c Deprecate MatrixEvent.toJSON (#3801)
* Deprecate `MatrixEvent.toJSON`

Per https://github.com/vector-im/element-web/issues/26380, this method is too
easy to use accidentally, and per the comments, it doesn't even return a
meaningful JSON-serialisation of the object.

* Update src/models/event.ts
2023-10-17 15:54:02 +00:00
renovate[bot] 9293986e3b Update definitelyTyped (#3775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-17 15:44:16 +00:00
renovate[bot] 8426d8cae1 Update babel monorepo (#3777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-17 15:07:20 +00:00
Andy Balaam 3baf6ec2c6 Add events to the thread even if they appear to be out of order (#3787) 2023-10-17 14:15:15 +00:00
ElementRobot 38cd6f93e6 v29.1.0-rc.1 2023-10-17 15:09:00 +01:00
ElementRobot a3a6742c67 Prepare changelog for v29.1.0-rc.1 2023-10-17 15:08:57 +01:00
David Baker 4ce837b20e Fix sending call member events on leave (#3799)
https://github.com/matrix-org/matrix-js-sdk/pull/3756 changed
the membership update function to await on the next call, but this
meant it never returned and therefore never cleared
`updateCallMembershipRunning`. We therefore didn't send the updated
call member event when leaving, instead sending it whenever the next
poll interval arrived.

This changes it to only await if we are retrying, not if we're just
scheduling the next poll.

Fixes https://github.com/vector-im/element-call/issues/1763
2023-10-17 13:26:18 +00:00
Michael Telatynski 884bd2585a Prettier
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-10-17 10:27:19 +01:00
Michael Telatynski c306d87f80 Add SUMMARY.md
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-10-17 10:01:17 +01:00
Andy Balaam b94d137398 Move redacted messages out of any thread, into main timeline.
For consistency with the spec at room version 11. See
https://github.com/matrix-org/matrix-spec-proposals/pull/3389
for a proposal to make this unnecessary.
2023-10-16 12:49:57 +01:00
Andy Balaam 5595e8497f Clarify code that chooses a thread ID to include in a receipt (#3797)
* Extract threadIdForReceipt function from sendReceipt

* Tests for threadIdForReceipt

* Correct test of threadIdForReceipt to expect main for redaction of threaded

* Expand and comment implementation of threadIdForReceipt
2023-10-16 10:35:36 +00:00
Valere 5d233f3863 Bump wasm bindings version to 2.0.0 (#3795) 2023-10-12 16:29:30 +01:00
Kerry 0f4fa5ad51 OIDC: refresh tokens (#3764)
* very messy poc

* iterate

* more types and use tokenRefreshFunction

* working refresh without persistence

* tidy

* add claims to completeauhtorizationcodegrant response

* export tokenrefresher from matrix

* add idtokenclaims

* add claims to completeauhtorizationcodegrant response

* only one token refresh attempt at a time

* tests

* comments

* add tokenRefresher class

* export generateScope

* export oidc from matrix

* test refreshtoken

* mark experimental

* add getRefreshToken to client

* Apply suggestions from code review

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

* remove some vars in test

* make TokenRefresher un-abstract, comments and improvements

* remove invalid jsdoc

* Update src/oidc/tokenRefresher.ts

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

* Code review improvements

* fix verification integ tests

* remove unused type from props

* fix incomplete mock fn in fetch.spec

* document TokenRefreshFunction

* comments

* tidying

* update for injected logger

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-11 22:00:02 +00:00
Kerry 1de6de05a1 add prompt param to OIDC auth url creation (#3794) 2023-10-11 02:20:23 +00:00
David Baker c8f8fb587d Don't use event.sender in CallMembership (#3793)
* Don't use event.sender in CallMembership

I fell into another js-sdk trap: this is "only guaranteed to be set
for events that appear in a timeline" and not state events. It does
not say why. We only ever used it to get the sender user ID anyway,
so just use getSender().

* Fix test
2023-10-10 15:19:52 +00:00
Richard van der Hoff 2f79e6c056 Element-R: Don't mark QR code verification as done until it's done (#3791)
* Element-R: Don't mark QR code verification as done too soon

The rust crypto sdk doesn't actually finish QR code verification until the
`m.key.verification.done` is received, so make sure we don't tell the
application it is done before that happens.

Fixes https://github.com/vector-im/element-web/issues/26293

* ignore fallback line

* Revert unnecessary changes

Can't get the coverage high enough on this and it's not needed.
2023-10-10 09:38:30 +00:00
Richard van der Hoff 42be793a56 Allow applications to specify their own logger instance (#3792)
* Support MatrixClient-specific loggers.

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>

* Use client-specific logger in client.ts.

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>

* Log `fetch` requests to the per-client logger

* Use client-specific logger in rust-crypto
2023-10-10 10:34:03 +01:00
Dharshan 7c2a12085c Fix small typo in README.md in root dir (#3788) 2023-10-10 08:37:04 +00:00
RiotRobot 3cf6f568f3 Resetting package fields for development 2023-10-10 09:13:20 +01:00
RiotRobot 4db08cb78e Merge branch 'master' into develop 2023-10-10 09:13:15 +01:00
RiotRobot 25e5d79cf6 v29.0.0 2023-10-10 09:13:04 +01:00
RiotRobot 6c8e3d0707 Prepare changelog for v29.0.0 2023-10-10 09:13:01 +01:00
Kerry 3139f5729b OIDC: Token refresher class (#3769)
* add tokenRefresher class

* export generateScope

* export oidc from matrix

* mark experimental

* Apply suggestions from code review

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

* remove some vars in test

* make TokenRefresher un-abstract, comments and improvements

* remove invalid jsdoc

* Update src/oidc/tokenRefresher.ts

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

* Code review improvements

* document TokenRefreshFunction

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-09 20:46:43 +00:00
Florian Duros bb8a894105 Call RustBackupManager.checkKeyBackupAndEnable when RustCrypto is created (#3784) 2023-10-09 13:23:45 +00:00
Richard van der Hoff 223dfffdfb Define a new Logger interface (#3789)
* rename loglevel import to loglevel

* Define new `Logger` interface to replace `PrefixedLogger`

* PrefixedLogger -> Logger in crypto store

* PrefixedLogger -> Logger in `src/crypto`

* PrefixedLogger -> Logger in rust-crypto
2023-10-09 13:06:16 +00:00
Andy Balaam f19f0a8793 Comments attempting to explain the addEvent method (#3786)
* Warn when we drop an event trying to add it to a thread

Added to try and help debug https://github.com/vector-im/element-web/issues/26254

* Comment trying to explain lastEvent

* Document some of my understanding of the addEvent logic

* Refer to stable spec instead of MSC

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

* Re-word comments based on review

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-10-09 12:47:16 +00:00
Timo a5224c1820 make leaveRoomSession async (#3756)
* make leaveRoomSession async.
This does not resolve the promise until the event is actually send.
No network connection would make awaiting on this blocking.

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

* add timeout to leave

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

* formatting

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2023-10-09 11:52:19 +00:00
Andy Balaam 513201b9c1 Warn when we drop an event trying to add it to a thread (#3785)
Added to try and help debug https://github.com/vector-im/element-web/issues/26254
2023-10-06 14:40:57 +00:00
Richard van der Hoff 02ca5c78cf Element-R: don't log every HTTP request (#3780)
Since #3485, we log every request anyway, so there's no need to log twice.,
2023-10-05 14:31:28 +00:00
Florian Duros af63d9bd05 Element-R: Avoid errors in VerificationRequest.generateQRCode when QR code is unavailable (#3779)
* Avoid `VerificationRequest.generateQRCode` to crash when QRCode is unavailable

* Add tests `can try to generate a QR code when QR code is not supported`
2023-10-05 08:17:39 +00:00
Valere 95baccfbc1 Rust crypto: ensure we persist the key backup version (#3770)
Fixes vector-im/element-web#26259
2023-10-04 11:38:50 +01:00
Richard van der Hoff 10b6c2463d Grab bag of Element-R cleanups (#3773)
* `RustBackupManager.getActiveBackupVersion`: check that backup is enabled

The previous check on `isBackupEnabled` was a no-op

* Fix log spam on shieldless events

* Reduce log spam about tracking users

* Reduce log spam about decrypting events

Logging the entire event is excessive
2023-10-04 09:15:54 +00:00
Kerry 6e8d15e5ed add claims to completeauhtorizationcodegrant response (#3765) 2023-10-04 05:05:54 +01:00
Florian Duros 2e4276437a ElementR: Check key backup when user identity changes (#3760)
Fixes vector-im/element-web#26244
2023-10-03 13:38:51 +01:00
Richard van der Hoff 6a761af867 Element-R: emit VerificationRequestReceived on incoming request (#3762) 2023-10-03 13:37:58 +01:00
Michael Telatynski 6eec2ceeeb Export AutoDiscoveryError and fix type of ALL_ERRORS (#3768) 2023-10-03 11:01:16 +01:00
83 changed files with 4099 additions and 965 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
# from creeping in. They take a long time to run and consume 4 concurrent runners.
if: github.event.workflow_run.event == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@66854039a33ed6cfe1fc635ff2daa8bb261c0b56
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.83.0-rc.1
permissions:
actions: read
issues: read
+1 -1
View File
@@ -20,7 +20,7 @@ concurrency:
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.80.1
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.82.0
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Check membership
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v3
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
+4 -3
View File
@@ -12,18 +12,19 @@ env:
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
jobs:
jest:
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node == '*' && 'latest' || matrix.node }})"
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
specs: [integ, unit]
node: [18, latest]
node: [18, "*"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
id: setupNode
uses: actions/setup-node@v3
with:
cache: "yarn"
@@ -51,7 +52,7 @@ jobs:
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
@@ -1,38 +0,0 @@
name: Upgrade Dependencies
on:
workflow_dispatch: {}
workflow_call:
secrets:
ELEMENT_BOT_TOKEN:
required: true
jobs:
upgrade:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
cache: "yarn"
- name: Upgrade
run: yarn upgrade && yarn install
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
delete-branch: true
title: Upgrade dependencies
labels: |
Dependencies
T-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
+4
View File
@@ -0,0 +1,4 @@
{
"*.(ts|tsx)": ["eslint --fix", "prettier --write"],
"*.(py|md|yaml)": ["prettier --write"]
}
+49 -2
View File
@@ -1,5 +1,52 @@
Changes in [29.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0-rc.1) (2023-10-03)
============================================================================================================
Changes in [30.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.1) (2023-11-13)
==================================================================================================
## 🐛 Bug Fixes
* Ensure `setUserCreator` is called when a store is assigned ([\#3867](https://github.com/matrix-org/matrix-js-sdk/pull/3867)). Fixes vector-im/element-web#26520. Contributed by @MidhunSureshR.
Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.0) (2023-11-07)
==================================================================================================
## 🚨 BREAKING CHANGES
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
## 🦖 Deprecations
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
## ✨ Features
* Element-R: Add the git sha of the binding crate to `CryptoApi#getVersion` ([\#3838](https://github.com/matrix-org/matrix-js-sdk/pull/3838)). Contributed by @florianduros.
* Element-R: Wire up `globalBlacklistUnverifiedDevices` field to rust crypto encryption settings ([\#3790](https://github.com/matrix-org/matrix-js-sdk/pull/3790)). Fixes vector-im/element-web#26315. Contributed by @florianduros.
* Element-R: Wire up room rotation ([\#3807](https://github.com/matrix-org/matrix-js-sdk/pull/3807)). Fixes vector-im/element-web#26318. Contributed by @florianduros.
* Element-R: Add current version of the rust-sdk and vodozemac ([\#3825](https://github.com/matrix-org/matrix-js-sdk/pull/3825)). Contributed by @florianduros.
* Element-R: Wire up room history visibility ([\#3805](https://github.com/matrix-org/matrix-js-sdk/pull/3805)). Fixes vector-im/element-web#26319. Contributed by @florianduros.
* Element-R: log when we send to-device messages ([\#3810](https://github.com/matrix-org/matrix-js-sdk/pull/3810)).
## 🐛 Bug Fixes
* Fix reemitter not being correctly wired on user objects created in storage classes ([\#3796](https://github.com/matrix-org/matrix-js-sdk/pull/3796)). Contributed by @MidhunSureshR.
* Element-R: silence log errors when viewing a pending event ([\#3824](https://github.com/matrix-org/matrix-js-sdk/pull/3824)).
* Don't emit a closed event if the indexeddb is closed by Element ([\#3832](https://github.com/matrix-org/matrix-js-sdk/pull/3832)). Fixes vector-im/element-web#25941. Contributed by @dhenneke.
* Element-R: silence log errors when viewing a decryption failure ([\#3821](https://github.com/matrix-org/matrix-js-sdk/pull/3821)).
Changes in [29.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.1.0) (2023-10-24)
==================================================================================================
## ✨ Features
* OIDC: refresh tokens ([\#3764](https://github.com/matrix-org/matrix-js-sdk/pull/3764)). Contributed by @kerryarchibald.
* OIDC: add `prompt` param to auth url creation ([\#3794](https://github.com/matrix-org/matrix-js-sdk/pull/3794)). Contributed by @kerryarchibald.
* Allow applications to specify their own logger instance ([\#3792](https://github.com/matrix-org/matrix-js-sdk/pull/3792)). Fixes #1899.
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
## 🐛 Bug Fixes
* Fix sending call member events on leave ([\#3799](https://github.com/matrix-org/matrix-js-sdk/pull/3799)). Fixes vector-im/element-call#1763.
* Don't use event.sender in CallMembership ([\#3793](https://github.com/matrix-org/matrix-js-sdk/pull/3793)).
* Element-R: Don't mark QR code verification as done until it's done ([\#3791](https://github.com/matrix-org/matrix-js-sdk/pull/3791)). Fixes vector-im/element-web#26293.
* Element-R: Connect device to key backup when crypto is created ([\#3784](https://github.com/matrix-org/matrix-js-sdk/pull/3784)). Fixes vector-im/element-web#26316. Contributed by @florianduros.
* Element-R: Avoid errors in `VerificationRequest.generateQRCode` when QR code is unavailable ([\#3779](https://github.com/matrix-org/matrix-js-sdk/pull/3779)). Fixes vector-im/element-web#26300. Contributed by @florianduros.
* ElementR: Check key backup when user identity changes ([\#3760](https://github.com/matrix-org/matrix-js-sdk/pull/3760)). Fixes vector-im/element-web#26244. Contributed by @florianduros.
* Element-R: emit `VerificationRequestReceived` on incoming request ([\#3762](https://github.com/matrix-org/matrix-js-sdk/pull/3762)). Fixes vector-im/element-web#26245.
Changes in [29.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0) (2023-10-10)
==================================================================================================
## 🚨 BREAKING CHANGES
* Remove browserify builds ([\#3759](https://github.com/matrix-org/matrix-js-sdk/pull/3759)).
+1 -1
View File
@@ -351,7 +351,7 @@ First, you need to pull in the right build tools:
## Building
To build a browser version from scratch when developing::
To build a browser version from scratch when developing:
```
$ yarn build
+8
View File
@@ -0,0 +1,8 @@
# Summary
- [Introduction](../README.md)
# Deep dive
- [Storage notes](storage-notes.md)
- [Unverified devices](warning-on-unverified-devices.md)
@@ -1,31 +1,29 @@
Random notes from Matthew on the two possible approaches for warning users about unexpected
unverified devices popping up in their rooms....
Original idea...
================
# Original idea...
Warn when an existing user adds an unknown device to a room.
Warn when a user joins the room with unverified or unknown devices.
Warn when you initial sync if the room has any unverified devices in it.
^ this is good enough if we're doing local storage.
OR, better:
^ this is good enough if we're doing local storage.
OR, better:
Warn when you initial sync if the room has any new undefined devices since you were last there.
=> This means persisting the rooms that devices are in, across initial syncs.
=> This means persisting the rooms that devices are in, across initial syncs.
Updated idea...
===============
# Updated idea...
Warn when the user tries to send a message:
- If the room has unverified devices which the user has not yet been told about in the context of this room
...or in the context of this user? currently all verification is per-user, not per-room.
- If the room has unverified devices which the user has not yet been told about in the context of this room
...or in the context of this user? currently all verification is per-user, not per-room.
...this should be good enough.
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
throw an error when trying to encrypt if there are pure unverified devices there
app will have to search for the devices which are pure unverified to warn about them - have to do this from MembersList anyway?
- or megolm could warn which devices are causing the problems.
- or megolm could warn which devices are causing the problems.
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "29.0.0-rc.1",
"version": "30.0.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -51,7 +51,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^2.2.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -78,8 +78,8 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "^2.2.5",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
@@ -95,7 +95,7 @@
"babelify": "^10.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.49.0",
"eslint": "8.51.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -106,12 +106,14 @@
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^48.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fake-indexeddb": "^5.0.0",
"fetch-mock-jest": "^1.5.1",
"husky": "^8.0.3",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.8",
"rimraf": "^5.0.0",
+348 -21
View File
@@ -23,7 +23,14 @@ import { MockResponse, MockResponseFunction } from "fetch-mock";
import Olm from "@matrix-org/olm";
import * as testUtils from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import {
advanceTimersUntil,
CRYPTO_BACKENDS,
getSyncResponse,
InitCrypto,
mkEventCustom,
syncPromise,
} from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
import {
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
@@ -37,6 +44,7 @@ import {
import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import {
Category,
ClientEvent,
createClient,
CryptoEvent,
@@ -53,11 +61,12 @@ import {
Room,
RoomMember,
RoomStateEvent,
HistoryVisibility,
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { escapeRegExp } from "../../../src/utils";
import { defer, escapeRegExp } from "../../../src/utils";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
import { flushPromises } from "../../test-utils/flushPromises";
import {
@@ -66,13 +75,19 @@ import {
mockSetupMegolmBackupRequests,
} from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/crypto-api";
import {
CrossSigningKey,
CryptoCallbacks,
EventShieldColour,
EventShieldReason,
KeyBackupInfo,
} from "../../../src/crypto-api";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { DecryptionError } from "../../../src/crypto/algorithms";
import { IKeyBackup } from "../../../src/crypto/backup";
import {
createOlmSession,
createOlmAccount,
createOlmSession,
encryptGroupSessionKey,
encryptMegolmEvent,
encryptMegolmEventRawPlainText,
@@ -80,6 +95,7 @@ import {
establishOlmSession,
getTestOlmAccountKeys,
} from "./olm-utils";
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -146,6 +162,27 @@ async function expectSendRoomKey(
});
}
/**
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
* @returns the content of the encrypted event
*/
function expectEncryptedSendMessage() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
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 },
);
});
}
/**
* Expect that the client sends an encrypted event
*
@@ -159,22 +196,7 @@ async function expectSendRoomKey(
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",
};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
const encryptedMessageContent = await expectEncryptedSendMessage();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
@@ -922,6 +944,210 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
aliceClient.sendTextMessage(ROOM_ID, "test"),
]);
});
it("should send a m.unverified code in toDevice messages to an unverified device when globalBlacklistUnverifiedDevices=true", async () => {
aliceClient.getCrypto()!.globalBlacklistUnverifiedDevices = true;
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
// Tell alice we share a room with bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Force alice to download bob keys
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// Wait to receive the toDevice message and return bob device content
const toDevicePromise = new Promise<ToDevicePayload>((resolve) => {
fetchMock.putOnce(new RegExp("/sendToDevice/m.room_key.withheld/"), (url, request) => {
const content = JSON.parse(request.body as string);
resolve(content.messages["@bob:xyz"]["DEVICE_ID"]);
return {};
});
});
// Mock endpoint of message sending
fetchMock.put(new RegExp("/send/"), { event_id: "$event_id" });
await aliceClient.sendTextMessage(ROOM_ID, "test");
// Finally, check that the toDevice message has the m.unverified code
const toDeviceContent = await toDevicePromise;
expect(toDeviceContent.code).toBe("m.unverified");
});
});
describe("Session should rotate according to encryption settings", () => {
/**
* Send a message to bob and get the encrypted message
* @returns {Promise<IContent>} The encrypted message
*/
async function sendEncryptedMessage(): Promise<IContent> {
const [encryptedMessage] = await Promise.all([
expectEncryptedSendMessage(),
aliceClient.sendTextMessage(ROOM_ID, "test"),
]);
return encryptedMessage;
}
afterEach(() => {
jest.useRealTimers();
});
newBackendOnly("should rotate the session after 2 messages", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
const syncResponse = getSyncResponse(["@bob:xyz"]);
// Every 2 messages in the room, the session should be rotated
syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = {
algorithm: "m.megolm.v1.aes-sha2",
rotation_period_msgs: 2,
};
// Tell alice we share a room with bob
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// Force alice to download bob keys
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// Send a message to bob and get the encrypted message
const [encryptedMessage] = await Promise.all([
sendEncryptedMessage(),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
]);
// Check that the session id exists
const sessionId = encryptedMessage.session_id;
expect(sessionId).toBeDefined();
// Send a second message to bob and get the current message
const secondEncryptedMessage = await sendEncryptedMessage();
// Check that the same session id is shared between the two messages
const secondSessionId = secondEncryptedMessage.session_id;
expect(secondSessionId).toBe(sessionId);
// The session should be rotated, we are expecting the room key to be sent
const [thirdEncryptedMessage] = await Promise.all([
sendEncryptedMessage(),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
]);
// The session is rotated every 2 messages, we should have a new session id
const thirdSessionId = thirdEncryptedMessage.session_id;
expect(thirdSessionId).not.toBe(sessionId);
});
newBackendOnly("should rotate the session after 1h", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
// We need to fake the timers to advance the time
jest.useFakeTimers();
const syncResponse = getSyncResponse(["@bob:xyz"]);
// The minimum rotation period is 1h
// https://github.com/matrix-org/matrix-rust-sdk/blob/f75b2cd1d0981db42751dadb08c826740af1018e/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs#L410-L415
const oneHourInMs = 60 * 60 * 1000;
// Every 1h the session should be rotated
syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = {
algorithm: "m.megolm.v1.aes-sha2",
rotation_period_ms: oneHourInMs,
};
// Tell alice we share a room with bob
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// Force alice to download bob keys
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// Send a message to bob and get the encrypted message
const [encryptedMessage] = await Promise.all([
sendEncryptedMessage(),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
]);
// Check that the session id exists
const sessionId = encryptedMessage.session_id;
expect(sessionId).toBeDefined();
// Advance the time by 1h
jest.advanceTimersByTime(oneHourInMs);
// Send a second message to bob and get the encrypted message
const [secondEncryptedMessage] = await Promise.all([
sendEncryptedMessage(),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
]);
// The session should be rotated
const secondSessionId = secondEncryptedMessage.session_id;
expect(secondSessionId).not.toBe(sessionId);
});
});
newBackendOnly("should rotate the session when the history visibility changes", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
// Tell alice we share a room with bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Force alice to download bob keys
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// Send a message to bob and get the current session id
let [, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
]);
// Check that the session id exists
const sessionId = encryptedMessage.session_id;
expect(sessionId).toBeDefined();
// Change history visibility in sync response
const syncResponse = getSyncResponse([]);
syncResponse.rooms[Category.Join][ROOM_ID].timeline.events.push(
mkEventCustom({
sender: TEST_USER_ID,
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: HistoryVisibility.Invited,
},
}),
);
// Update the new visibility
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// Resend a message to bob and get the new session id
[, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
]);
// Check that the new session id exists
const newSessionId = encryptedMessage.session_id;
expect(newSessionId).toBeDefined();
// Check that the session id has changed
expect(sessionId).not.toEqual(newSessionId);
});
oldBackendOnly("We should start a new megolm session when a device is blocked", async () => {
@@ -1700,6 +1926,105 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await sendEventPromise;
});
describe("getEncryptionInfoForEvent", () => {
it("handles outgoing events", 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);
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
const sendRoomMessageDefer = defer<MockResponse>();
const reqProm = new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
async (url: string, opts: RequestInit): Promise<MockResponse> => {
resolve(JSON.parse(opts.body as string));
return await sendRoomMessageDefer.promise;
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
// Now we start to send the message
const sendProm = aliceClient.sendTextMessage(testData.TEST_ROOM_ID, "test");
// and wait for the outgoing requests
const inboundGroupSession = await inboundGroupSessionPromise;
const encryptedMessageContent = await reqProm;
const msg: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
logger.log("Decrypted received megolm message", msg);
// at this point, the request to send the room message has been made, but not completed.
// get hold of the pending event, and see what getEncryptionInfoForEvent makes of it
const pending = aliceClient.getRoom(testData.TEST_ROOM_ID)!.getPendingEvents();
expect(pending.length).toEqual(1);
const encInfo = await aliceClient.getCrypto()!.getEncryptionInfoForEvent(pending[0]);
expect(encInfo!.shieldColour).toEqual(EventShieldColour.NONE);
expect(encInfo!.shieldReason).toBeNull();
// release the send request
const resp = { event_id: "$event_id" };
sendRoomMessageDefer.resolve(resp);
expect(await sendProm).toEqual(resp);
// still pending at this point
expect(aliceClient.getRoom(testData.TEST_ROOM_ID)!.getPendingEvents().length).toEqual(1);
// echo the event back
const fullEvent = {
event_id: "$event_id",
type: "m.room.encrypted",
sender: aliceClient.getUserId(),
origin_server_ts: Date.now(),
content: encryptedMessageContent,
};
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
rooms: { join: { [testData.TEST_ROOM_ID]: { timeline: { events: [fullEvent] } } } },
});
await syncPromise(aliceClient);
const timelineEvents = aliceClient.getRoom(testData.TEST_ROOM_ID)!.getLiveTimeline()!.getEvents();
const lastEvent = timelineEvents[timelineEvents.length - 1];
expect(lastEvent.getId()).toEqual("$event_id");
// now check getEncryptionInfoForEvent again
const encInfo2 = await aliceClient.getCrypto()!.getEncryptionInfoForEvent(lastEvent);
let expectedEncryptionInfo;
if (backend === "rust-sdk") {
// rust crypto does not trust its own device until it is cross-signed.
expectedEncryptionInfo = {
shieldColour: EventShieldColour.RED,
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
};
} else {
expectedEncryptionInfo = {
shieldColour: EventShieldColour.NONE,
shieldReason: null,
};
}
expect(encInfo2).toEqual(expectedEncryptionInfo);
});
});
describe("Lazy-loading member lists", () => {
let p2pSession: Olm.Session;
@@ -2483,7 +2808,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", keyBackupData);
// should be able to restore from 4S
const importResult = await aliceClient.restoreKeyBackupWithSecretStorage(check!.backupInfo!);
const importResult = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithSecretStorage(check!.backupInfo!),
);
expect(importResult.imported).toStrictEqual(1);
});
+149 -50
View File
@@ -23,10 +23,17 @@ import { SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import {
advanceTimersUntil,
awaitDecryption,
CRYPTO_BACKENDS,
InitCrypto,
syncPromise,
} from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import { IKeyBackup } from "../../../src/crypto/backup";
import { flushPromises } from "../../test-utils/flushPromises";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -110,9 +117,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
jest.useFakeTimers();
beforeEach(async () => {
jest.useFakeTimers();
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
@@ -134,6 +141,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
await jest.runAllTimersAsync();
fetchMock.mockReset();
jest.restoreAllMocks();
});
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
@@ -149,48 +157,131 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
return client;
}
it("Alice checks key backups when receiving a message she can't decrypt", async function () {
const syncResponse = {
describe("Key backup check on UTD message", () => {
// sync response which contains an encrypted event
const SYNC_RESPONSE = {
next_batch: 1,
rooms: {
join: {
[ROOM_ID]: {
timeline: {
events: [testData.ENCRYPTED_EVENT],
},
},
},
},
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
};
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
testData.CURVE25519_KEY_BACKUP_DATA,
);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const EXPECTED_URL =
[
"https://alice-server.com/_matrix/client/v3/room_keys/keys",
encodeURIComponent(testData.TEST_ROOM_ID),
encodeURIComponent(testData.MEGOLM_SESSION_DATA.session_id),
].join("/") + "?version=1";
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
/** Flush promises enough times to get the crypto stacks to make the backup request */
async function flushBackupRequest() {
// we have to run flushPromises lots of times. It seems like each time the rust code touches indexeddb,
// it needs another round of flushPromises to progress, or something.
for (let i = 0; i < 10; i++) {
await flushPromises();
}
}
// start after saving the private key
await aliceClient.startClient();
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
// ignore requests to send room key requests
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await awaitDecryption(event, { waitOnDecryptionFailure: true });
// start after saving the private key
await aliceClient.startClient();
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
});
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
// check that the version is correct
const version = new URLSearchParams(new URL(url).search).get("version");
if (version == "1") {
return testData.CURVE25519_KEY_BACKUP_DATA;
} else {
return {
status: 403,
body: {
current_version: "1",
errcode: "M_WRONG_ROOM_KEYS_VERSION",
error: "Wrong backup version.",
},
};
}
});
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
});
it("handles error on backup query gracefully", async () => {
jest.spyOn(console, "error").mockImplementation(() => {});
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
{ name: "getKey" },
);
// Send Alice a message that she won't be able to decrypt
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
await flushBackupRequest();
const calls = fetchMock.calls("getKey");
expect(calls.length).toEqual(1);
expect(calls[0][0]).toEqual(EXPECTED_URL);
await flushBackupRequest();
// we should not have logged an error.
// eslint-disable-next-line no-console
expect(console.error).not.toHaveBeenCalled();
});
it("Only queries once", async () => {
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
{ name: "getKey" },
);
// Send Alice a message that she won't be able to decrypt
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
await flushBackupRequest();
const calls = fetchMock.calls("getKey");
expect(calls.length).toEqual(1);
expect(calls[0][0]).toEqual(EXPECTED_URL);
fetchMock.resetHistory();
// another message
const event2 = { ...testData.ENCRYPTED_EVENT, event_id: "$event2" };
const syncResponse2 = {
next_batch: 1,
rooms: { join: { [ROOM_ID]: { timeline: { events: [event2] } } } },
};
syncResponder.sendOrQueueSyncResponse(syncResponse2);
await flushBackupRequest();
expect(fetchMock.calls("getKey").length).toEqual(0);
});
});
describe("recover from backup", () => {
@@ -224,14 +315,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
onKeyCached = resolve;
});
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
cacheCompleteCallback: () => onKeyCached(),
},
const result = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
cacheCompleteCallback: () => onKeyCached(),
},
),
);
expect(result.imported).toStrictEqual(1);
@@ -239,7 +332,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
await awaitKeyCached;
// The key should be now cached
const afterCache = await aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!);
const afterCache = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!),
);
expect(afterCache.imported).toStrictEqual(1);
});
@@ -262,11 +357,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const check = await aliceCrypto.checkKeyBackupAndEnable();
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
ROOM_ID,
testData.MEGOLM_SESSION_DATA.session_id,
check!.backupInfo!,
const result = await advanceTimersUntil(
aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
ROOM_ID,
testData.MEGOLM_SESSION_DATA.session_id,
check!.backupInfo!,
),
);
expect(result.imported).toStrictEqual(1);
@@ -645,6 +742,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
await aliceClient.startClient();
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
@@ -658,6 +756,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
await aliceClient.startClient();
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
+154 -1
View File
@@ -17,10 +17,11 @@ limitations under the License.
import Olm from "@matrix-org/olm";
import anotherjson from "another-json";
import { IContent, IDeviceKeys, IEvent, MatrixClient } from "../../../src";
import { IContent, IDeviceKeys, IDownloadKeyResult, IEvent, Keys, MatrixClient, SigningKeys } from "../../../src";
import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { ISyncResponder } from "../../test-utils/SyncResponder";
import { syncPromise } from "../../test-utils/test-utils";
import { KeyBackupInfo } from "../../../src/crypto-api";
/**
* @module
@@ -60,6 +61,117 @@ export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, d
return testDeviceKeys;
}
/**
* Bootstrap cross signing for the given Olm account.
*
* Will generate the cross signing keys and sign them with the master key, and returns the `IDownloadKeyResult`
* that can be directly fed into a test e2eKeyResponder.
*
* The cross-signing keys are randomly generated, similar to how the olm account keys are generated. There may not
* be any value in using static vectors, as the device keys change at every test run.
*
* If some `KeyBackupInfo` are provided, the `auth_data` of each backup info will be signed with the
* master key, meaning the backups will be then trusted after verification.
*
* @param olmAccount - The Olm account object to use for signing the device keys.
* @param userId - The user ID to associate with the device keys.
* @param deviceId - The device ID to associate with the device keys.
* @param keyBackupInfo - Optional key backup infos to sign with the master key.
* @returns A valid keys/query response that can be fed into a test e2eKeyResponder.
*/
export function bootstrapCrossSigningTestOlmAccount(
olmAccount: Olm.Account,
userId: string,
deviceId: string,
keyBackupInfo: KeyBackupInfo[] = [],
): Partial<IDownloadKeyResult> {
const olmAliceMSK = new global.Olm.PkSigning();
const masterPrivkey = olmAliceMSK.generate_seed();
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
const olmAliceUSK = new global.Olm.PkSigning();
const userPrivkey = olmAliceUSK.generate_seed();
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
const olmAliceSSK = new global.Olm.PkSigning();
const sskPrivkey = olmAliceSSK.generate_seed();
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
const mskInfo: Keys = {
user_id: userId,
usage: ["master"],
keys: {
["ed25519:" + masterPubkey]: masterPubkey,
},
};
const sskInfo: Partial<SigningKeys> = {
user_id: userId,
usage: ["self_signing"],
keys: {
["ed25519:" + sskPubkey]: sskPubkey,
},
};
// sign the ssk with the msk
const sskSig = olmAliceMSK.sign(anotherjson.stringify(sskInfo));
sskInfo.signatures = {
[userId]: {
["ed25519:" + masterPubkey]: sskSig,
},
};
const uskInfo: Partial<SigningKeys> = {
user_id: userId,
usage: ["user_signing"],
keys: {
["ed25519:" + userPubkey]: userPubkey,
},
};
// sign the usk with the msk
const uskSig = olmAliceMSK.sign(anotherjson.stringify(uskInfo));
uskInfo.signatures = {
[userId]: {
["ed25519:" + masterPubkey]: uskSig,
},
};
// get the device keys and sign them with the ssk (the device is then cross signed)
const deviceKeys = getTestOlmAccountKeys(olmAccount, userId, deviceId);
const copy = Object.assign({}, deviceKeys);
delete copy.signatures;
const crossSignature = olmAliceSSK.sign(anotherjson.stringify(copy));
// add the signature
deviceKeys.signatures![userId]["ed25519:" + sskPubkey] = crossSignature;
// if we have some key backup info, sign them with the msk
keyBackupInfo.forEach((info) => {
const unsignedAuthData = Object.assign({}, info.auth_data);
delete unsignedAuthData.signatures;
const backupSignature = olmAliceMSK.sign(anotherjson.stringify(unsignedAuthData));
info.auth_data.signatures = {
[userId]: {
["ed25519:" + masterPubkey]: backupSignature,
},
};
});
// clean the olm resources as we don't need them anymore
olmAliceMSK.free();
olmAliceSSK.free();
olmAliceUSK.free();
return {
master_keys: { [userId]: mskInfo },
user_signing_keys: { [userId]: uskInfo as SigningKeys },
self_signing_keys: { [userId]: sskInfo as SigningKeys },
device_keys: { [userId]: { [deviceId]: deviceKeys } },
};
}
/** start an Olm session with a given recipient */
export async function createOlmSession(
olmAccount: Olm.Account,
@@ -218,6 +330,47 @@ export function encryptGroupSessionKey(opts: {
});
}
/**
* Test utility to correctly encrypt a secret send event to a test device using the provided p2p session.
*
* @param opts - the options for the secret send event
* @returns the to-device event, ready to be returned in a sync response for the test device.
*/
export function encryptSecretSend(opts: {
/** the sender's user id */
sender: string;
/** recipient's user id */
recipient: string;
/** the recipient's curve25519 key */
recipientCurve25519Key: string;
/** the recipient's ed25519 key */
recipientEd25519Key: string;
/** sender's olm account */
olmAccount: Olm.Account;
/** sender's olm session with the recipient */
p2pSession: Olm.Session;
/** The requestId of the secret request that this secret send is replying. */
requestId: string;
/** The secret value */
secret: string;
}): ToDeviceEvent {
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
return encryptOlmEvent({
sender: opts.sender,
senderKey: senderKeys.curve25519,
senderSigningKey: senderKeys.ed25519,
recipient: opts.recipient,
recipientCurve25519Key: opts.recipientCurve25519Key,
recipientEd25519Key: opts.recipientEd25519Key,
p2pSession: opts.p2pSession,
plaincontent: {
request_id: opts.requestId,
secret: opts.secret,
},
plaintype: "m.secret.send",
});
}
/**
* Establish an Olm Session with the test user
*
+456 -18
View File
@@ -26,6 +26,7 @@ import Olm from "@matrix-org/olm";
import {
createClient,
CryptoEvent,
DeviceVerification,
IContent,
ICreateClientOpts,
IEvent,
@@ -43,7 +44,7 @@ import {
Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { defer, escapeRegExp } from "../../../src/utils";
import {
awaitDecryption,
CRYPTO_BACKENDS,
@@ -54,10 +55,12 @@ import {
} from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
BACKUP_DECRYPTION_KEY_BASE64,
BOB_ONE_TIME_KEYS,
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
BOB_SIGNED_TEST_DEVICE_DATA,
BOB_TEST_USER_ID,
CURVE25519_KEY_BACKUP_DATA,
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
@@ -69,7 +72,16 @@ import {
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { createOlmSession, encryptGroupSessionKey, encryptMegolmEvent, ToDeviceEvent } from "./olm-utils";
import {
bootstrapCrossSigningTestOlmAccount,
createOlmSession,
encryptGroupSessionKey,
encryptMegolmEvent,
encryptSecretSend,
ToDeviceEvent,
} from "./olm-utils";
import { KeyBackupInfo } from "../../../src/crypto-api";
import { encodeBase64 } from "../../../src/base64";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
@@ -148,6 +160,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
beforeEach(async () => {
// pretend that we have another device, which we will verify
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
fetchMock.put(
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp("m.secret.request")}`),
{ ok: false, status: 404 },
{ overwriteRoutes: true },
);
});
// test with (1) the default verification method list, (2) a custom verification method list.
@@ -419,6 +437,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
});
it("can verify another via QR code with an untrusted cross-signing key", async () => {
// This is a slightly weird thing to test; if we don't trust the cross-signing key, normally we would
// spam out a verification request to all devices rather than targeting a single device. Still, it's
// a thing both the Matrix protocol and the js-sdk API support, so we may as well test it.
//
// Since we don't yet trust the master key, this is a type 0x02 QR code:
// "self-verifying in which the current device does not yet trust the master key"
//
// By the end of it, we should trust the master key.
aliceClient = await startTestClient();
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
@@ -490,12 +517,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
reciprocateQRCodeCallbacks.confirm();
await sendToDevicePromise;
// at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done".
// Rust crypto, on the other hand, waits for the 'done' to arrive from the other side.
if (request.phase === VerificationPhase.Done) {
// legacy crypto: we're all done
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
// eslint-disable-next-line jest/no-conditional-expect
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
await verificationPromise;
} else {
// rust crypto: still in flight
// eslint-disable-next-line jest/no-conditional-expect
expect(request.phase).toEqual(VerificationPhase.Started);
}
// the dummy device replies with its own 'done'
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
// ... and the whole thing should be done!
// ... and now we're really done.
await verificationPromise;
expect(request.phase).toEqual(VerificationPhase.Done);
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
});
it("can try to generate a QR code when QR code is not supported", async () => {
aliceClient = await startTestClient();
// we need cross-signing keys for a QR code verification
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
await waitForDeviceList();
// Alice sends a m.key.verification.request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
// The dummy device replies with an m.key.verification.ready, indicating it can only use SaS
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
// Alice tries to generate a QR Code but it's unavailable
const qrCodeBuffer = await request.generateQRCode();
expect(qrCodeBuffer).toBeUndefined();
});
newBackendOnly("can verify another by scanning their QR code", async () => {
@@ -980,12 +1046,43 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
});
}
it("Verification request not found", async () => {
// Expect to not find any verification request
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
expect(request).toBeUndefined();
});
it("ignores old verification requests", async () => {
const eventHandler = jest.fn();
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
const verificationRequestEvent = createVerificationRequestEvent();
verificationRequestEvent.origin_server_ts -= 1000000;
returnRoomMessageFromSync(TEST_ROOM_ID, verificationRequestEvent);
await syncPromise(aliceClient);
// make sure the event has arrived
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
const matrixEvent = room.getLiveTimeline().getEvents()[0];
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
// check that an event has not been raised, and that the request is not found
expect(eventHandler).not.toHaveBeenCalled();
expect(
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
).not.toBeDefined();
});
it("Plaintext verification request from Bob to Alice", async () => {
// Add verification request from Bob to Alice in the DM between them
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
// Wait for the sync response to be processed
await syncPromise(aliceClient);
// Wait for the request to be received
const request1 = await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
expect(request1.roomId).toBe(TEST_ROOM_ID);
expect(request1.isSelfVerification).toBe(false);
expect(request1.otherUserId).toBe("@bob:xyz");
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// Expect to find the verification request received during the sync
@@ -994,12 +1091,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(request?.otherUserId).toBe("@bob:xyz");
});
it("Verification request not found", async () => {
// Expect to not find any verification request
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
expect(request).not.toBeDefined();
});
it("Encrypted verification request from Bob to Alice", async () => {
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
const groupSession = new Olm.OutboundGroupSession();
@@ -1021,14 +1112,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await awaitDecryption(matrixEvent);
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
const requestEventPromise = emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
// Send Bob the room keys
returnToDeviceMessageFromSync(toDeviceEvent);
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
await jest.advanceTimersByTimeAsync(10);
// Wait for the message to be decrypted
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
// Wait for the request to be decrypted
const request1 = await requestEventPromise;
expect(request1.roomId).toBe(TEST_ROOM_ID);
expect(request1.isSelfVerification).toBe(false);
expect(request1.otherUserId).toBe("@bob:xyz");
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// Expect to find the verification request received during the sync
@@ -1076,6 +1172,292 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
);
});
describe("Secrets are gossiped after verification", () => {
// We use a legacy olm session as the existing session.
// This will give us access to low level olm functions in order to
// simulate a backup key request with proper olm encryption.
let testOlmAccount: Olm.Account;
const olmDeviceId = "OLM_DEVICE";
let usermasterPubKey: string;
const matchingBackupInfo: KeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const nonMatchingBackupInfo: KeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
},
};
const unknownAlgorithmBackupInfo: KeyBackupInfo = {
algorithm: "m.megolm_backup.foo_bar",
version: "1",
auth_data: {
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
},
};
beforeEach(async () => {
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
testOlmAccount = new Olm.Account();
testOlmAccount.create();
const bootstrapped = bootstrapCrossSigningTestOlmAccount(testOlmAccount, TEST_USER_ID, olmDeviceId, [
matchingBackupInfo,
nonMatchingBackupInfo,
]);
e2eKeyResponder.addDeviceKeys(bootstrapped.device_keys![TEST_USER_ID]![olmDeviceId]);
e2eKeyResponder.addCrossSigningData(bootstrapped);
usermasterPubKey = Object.values(bootstrapped.master_keys![TEST_USER_ID].keys)[0];
aliceClient = await startTestClient();
syncResponder.sendOrQueueSyncResponse(getSyncResponse([TEST_USER_ID]));
await syncPromise(aliceClient);
// DeviceList has a sleep(5) which we need to make happen
await jest.advanceTimersByTimeAsync(10);
// The client should now know about the olm device
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(olmDeviceId);
});
afterEach(async () => {
aliceClient?.stopClient();
testOlmAccount?.free();
// Allow in-flight things to complete before we tear down the test
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
newBackendOnly("Should request cross signing keys after verification", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
// The secret must have been requested
await requestPromises.get("m.cross_signing.master");
await requestPromises.get("m.cross_signing.user_signing");
await requestPromises.get("m.cross_signing.self_signing");
});
newBackendOnly("Should accept the backup decryption key gossip if valid", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
const requestId = await requestPromises.get("m.megolm_backup.v1");
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
// the backup secret should be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
expect(cachedKey).toBeTruthy();
expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64);
});
newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
const requestId = await requestPromises.get("m.megolm_backup.v1");
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, nonMatchingBackupInfo);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
const requestId = await requestPromises.get("m.megolm_backup.v1");
const infoCopy = Object.assign({}, matchingBackupInfo);
delete infoCopy.auth_data.signatures;
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, infoCopy);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
const requestId = await requestPromises.get("m.megolm_backup.v1");
await sendBackupGossipAndExpectVersion(
requestId!,
BACKUP_DECRYPTION_KEY_BASE64,
unknownAlgorithmBackupInfo,
);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
expect(cachedKey).toBeNull();
});
newBackendOnly("Should not accept an invalid backup decryption key", async () => {
const requestPromises = mockSecretRequestAndGetPromises();
await doInteractiveVerification();
const requestId = await requestPromises.get("m.megolm_backup.v1");
await sendBackupGossipAndExpectVersion(requestId!, "InvalidSecret", matchingBackupInfo);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
expect(cachedKey).toBeNull();
});
/**
* Common test setup for gossiping secrets.
* Creates a peer to peer session, sends the secret, mockup the version API, send the secret back from sync, then await for the backup check.
*/
async function sendBackupGossipAndExpectVersion(
requestId: string,
secret: string,
expectBackup: KeyBackupInfo,
) {
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
const toDeviceEvent = encryptSecretSend({
sender: aliceClient.getUserId()!,
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
p2pSession: p2pSession,
olmAccount: testOlmAccount,
requestId: requestId!,
secret: secret,
});
const expectBackupCheck = new Promise((resolve) => {
fetchMock.get(
"express:/_matrix/client/v3/room_keys/version",
(url, request) => {
resolve(undefined);
return expectBackup;
},
{
overwriteRoutes: true,
},
);
});
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", CURVE25519_KEY_BACKUP_DATA);
// The dummy device sends the secret
returnToDeviceMessageFromSync(toDeviceEvent);
await expectBackupCheck;
}
/**
* Do an interactive verification between alice and the dummy device.
*/
async function doInteractiveVerification(): Promise<void> {
// Do a QR code verification for simplicity
// Alice sends a m.key.verification.request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, olmDeviceId),
]);
const transactionId = request.transactionId!;
// The dummy device replies with an m.key.verification.ready, indicating it can show a QR code
returnToDeviceMessageFromSync(
buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"], olmDeviceId),
);
await waitForVerificationRequestChanged(request);
const currentDeviceKey = e2eKeyReceiver.getSigningKey();
// the dummy device shows a QR code
const sharedSecret = "SUPERSEKRET";
// use mode 0x01, self-verifying in which the current device does trust the master key
const mode = 0x01;
const qrCodeBuffer = buildQRCode(transactionId, usermasterPubKey, currentDeviceKey, sharedSecret, mode);
// Alice scans the QR code
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
const verifier = await request.scanQRCode(qrCodeBuffer);
await sendToDevicePromise;
const verificationPromise = verifier.verify();
// the dummy device confirms that Alice scanned the QR code, by replying with a done
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
// Alice also replies with a 'done'
await expectSendToDeviceMessage("m.key.verification.done");
// ... and the whole thing should be done!
await verificationPromise;
// The other device should now be verified.
const otherDevice = (await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]))
.get(TEST_USER_ID)!
.get(olmDeviceId);
expect(otherDevice?.verified).toEqual(DeviceVerification.Verified);
}
});
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
const client = createClient({
baseUrl: TEST_HOMESERVER_URL,
@@ -1137,6 +1519,52 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
});
}
/**
* Utility to add all needed mocks for secret requesting (to-device of type `m.secret.request`).
*
* The following secrets are mocked: `m.cross_signing.master`, `m.cross_signing.self_signing`,
* `m.cross_signing.user_signing`, `m.megolm_backup.v1`.
*
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
*/
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
const mskRequestDefer = defer<string>();
const sskRequestDefer = defer<string>();
const uskRequestDefer = defer<string>();
const backupKeyRequestDefer = defer<string>();
fetchMock.put(
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
(url: string, opts: RequestInit): MockResponse => {
const messages = JSON.parse(opts.body as string).messages[TEST_USER_ID];
// rust crypto broadcasts to all devices, old crypto to a specific device, take the first one
const content = Object.values(messages)[0] as any;
if (content.action == "request") {
const name = content.name;
const requestId = content.request_id;
if (name == "m.cross_signing.user_signing") {
uskRequestDefer.resolve(requestId);
} else if (name == "m.cross_signing.master") {
mskRequestDefer.resolve(requestId);
} else if (name == "m.cross_signing.self_signing") {
sskRequestDefer.resolve(requestId);
} else if (name == "m.megolm_backup.v1") {
backupKeyRequestDefer.resolve(requestId);
}
}
return {};
},
{ overwriteRoutes: true },
);
const promiseMap = new Map<string, Promise<string>>();
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
return promiseMap;
}
/** wait for the verification request to emit a 'Change' event */
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
return new Promise<void>((resolve) => {
@@ -1181,12 +1609,16 @@ function buildRequestMessage(transactionId: string): { type: string; content: ob
};
}
/** build an m.key.verification.ready to-device message originating from the dummy device */
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
/** build an m.key.verification.ready to-device message originating from the given `fromDevice` (default to `TEST_DEVICE_ID` if not provided) */
function buildReadyMessage(
transactionId: string,
methods: string[],
fromDevice?: string,
): { type: string; content: object } {
return {
type: "m.key.verification.ready",
content: {
from_device: TEST_DEVICE_ID,
from_device: fromDevice || TEST_DEVICE_ID,
methods: methods,
transaction_id: transactionId,
},
@@ -1284,14 +1716,20 @@ function buildDoneMessage(transactionId: string) {
};
}
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array {
function buildQRCode(
transactionId: string,
key1Base64: string,
key2Base64: string,
sharedSecret: string,
mode = 0x02,
): Uint8Array {
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
const qrCodeBuffer = Buffer.alloc(150); // oversize
let idx = 0;
idx += qrCodeBuffer.write("MATRIX", idx, "ascii");
idx = qrCodeBuffer.writeUInt8(0x02, idx); // version
idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode
idx = qrCodeBuffer.writeUInt8(mode, idx); // mode
idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx);
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
@@ -194,6 +194,37 @@ describe("MatrixClient events", function () {
expect(fired).toBe(true);
});
it("should emit User events when presence data is absent in first sync", async () => {
const MODIFIED_SYNC_DATA: any = structuredClone(SYNC_DATA);
delete MODIFIED_SYNC_DATA["presence"];
const MODIFIED_NEXT_SYNC_DATA: any = structuredClone(NEXT_SYNC_DATA);
MODIFIED_NEXT_SYNC_DATA.presence = {
events: [
utils.mkPresence({
user: "@foo:bar",
name: "Foo Bar",
presence: "online",
}),
],
};
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_SYNC_DATA);
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_NEXT_SYNC_DATA);
let fired = false;
client!.on(UserEvent.Presence, function (event, user) {
fired = true;
expect(user).toBeTruthy();
expect(event).toBeTruthy();
if (!user || !event) {
return;
}
expect(event.event).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]);
expect(user.presence).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]?.content?.presence);
});
client!.startClient();
await httpBackend!.flushAllExpected();
expect(fired).toBe(true);
});
it("should emit Room events", function () {
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
+22 -2
View File
@@ -537,8 +537,6 @@ export async function awaitDecryption(
});
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
app_display_name: "app",
app_id: "123",
@@ -561,3 +559,25 @@ CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
if (global.Olm) {
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto();
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
/**
* Advance the fake timers in a loop until the given promise resolves or rejects.
*
* Returns the result of the promise.
*
* This can be useful when there are multiple steps in the code which require an iteration of the event loop.
*/
export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
let resolved = false;
promise.finally(() => {
resolved = true;
});
while (!resolved) {
await jest.advanceTimersByTimeAsync(1);
}
return await promise;
}
@@ -15,16 +15,44 @@ limitations under the License.
*/
import { TextEncoder, TextDecoder } from "util";
import NodeBuffer from "node:buffer";
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../../src/common-crypto/base64";
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
let origBuffer = Buffer;
beforeAll(() => {
if (env === "browser") {
origBuffer = Buffer;
// @ts-ignore
// eslint-disable-next-line no-global-assign
Buffer = undefined;
global.atob = NodeBuffer.atob;
global.btoa = NodeBuffer.btoa;
}
});
afterAll(() => {
// eslint-disable-next-line no-global-assign
Buffer = origBuffer;
// @ts-ignore
global.atob = undefined;
// @ts-ignore
global.btoa = undefined;
});
describe("Crypto Base64 encoding", () => {
it("Should decode properly encoded data", async () => {
const toEncode = "encoding hello world";
const encoded = encodeBase64(new TextEncoder().encode(toEncode));
const decoded = new TextDecoder().decode(decodeBase64(encoded));
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));
expect(decoded).toStrictEqual(toEncode);
expect(decoded).toStrictEqual("encoding hello world");
});
it("Should decode URL-safe base64", async () => {
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
expect(decoded).toStrictEqual("?????");
});
it("Encode unpadded should not have padding", async () => {
+11 -1
View File
@@ -115,6 +115,16 @@ describe("Crypto", function () {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
it("getVersion() should return the current version of the olm library", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const olmVersionTuple = Crypto.getOlmVersion();
expect(client.getCrypto()?.getVersion()).toBe(
`Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`,
);
});
describe("encrypted events", function () {
it("provides encryption information for events from unverified senders", async function () {
const client = new TestClient("@alice:example.com", "deviceid").client;
@@ -1106,7 +1116,7 @@ describe("Crypto", function () {
describe("Secret storage", function () {
it("creates secret storage even if there is no keyInfo", async function () {
jest.spyOn(logger, "log").mockImplementation(() => {});
jest.spyOn(logger, "debug").mockImplementation(() => {});
jest.setTimeout(10000);
const client = new TestClient("@a:example.com", "dev").client;
await client.initCrypto();
+16
View File
@@ -229,6 +229,22 @@ describe("MegolmBackup", function () {
);
});
test("fail if given backup has no version", async () => {
const client = makeTestClient(cryptoStore);
await client.initCrypto();
const data = {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1");
await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow(
"Backup version must be defined",
);
});
it("automatically calls the key back up", function () {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
+5 -4
View File
@@ -28,6 +28,7 @@ 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";
import { decodeBase64 } from "../../../src/base64";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
@@ -275,13 +276,13 @@ describe("Secrets", function () {
describe("bootstrap", function () {
// keys used in some of the tests
const XSK = new Uint8Array(olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0";
const USK = new Uint8Array(olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU";
const SSK = new Uint8Array(olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q";
const SSSSKey = new Uint8Array(olmlib.decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
it("bootstraps when no storage or cross-signing keys locally", async function () {
const key = new Uint8Array(16);
@@ -16,7 +16,7 @@ limitations under the License.
import "../../../olm-loader";
import { MatrixClient, MatrixEvent } from "../../../../src/matrix";
import { encodeBase64 } from "../../../../src/crypto/olmlib";
import { encodeBase64 } from "../../../../src/base64";
import "../../../../src/crypto"; // import this to cycle-break
import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning";
import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
+1
View File
@@ -34,6 +34,7 @@ describe("eventMapperFor", function () {
getRoom(roomId: string): Room | null {
return rooms.find((r) => r.roomId === roomId) ?? null;
},
setUserCreator(_) {},
} as IStore,
scheduler: {
setProcessFunction: jest.fn(),
+161 -14
View File
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { Mocked } from "jest-mock";
import { FetchHttpApi } from "../../../src/http-api/fetch";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
import {
ClientPrefix,
HttpApiEvent,
HttpApiEventHandlerMap,
IdentityPrefix,
IHttpOpts,
MatrixError,
Method,
} from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { defer, QueryDict } from "../../../src/utils";
import { logger } from "../../../src/logger";
import { Logger } from "../../../src/logger";
describe("FetchHttpApi", () => {
const baseUrl = "http://baseUrl";
@@ -231,13 +239,145 @@ describe("FetchHttpApi", () => {
});
describe("authedRequest", () => {
it("should not include token if unset", () => {
const fetchFn = jest.fn();
it("should not include token if unset", async () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
api.authedRequest(Method.Post, "/account/password");
await api.authedRequest(Method.Post, "/account/password");
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
});
describe("with refresh token", () => {
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
describe("when an unknown token error is encountered", () => {
const unknownTokenErrBody = {
errcode: "M_UNKNOWN_TOKEN",
error: "Token is not active",
soft_logout: false,
};
const unknownTokenErr = new MatrixError(unknownTokenErrBody, 401);
const unknownTokenResponse = {
ok: false,
status: 401,
headers: {
get(name: string): string | null {
return name === "Content-Type" ? "application/json" : null;
},
},
text: jest.fn().mockResolvedValue(JSON.stringify(unknownTokenErrBody)),
};
const okayResponse = {
ok: true,
status: 200,
};
describe("without a tokenRefreshFunction", () => {
it("should emit logout and throw", async () => {
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
});
describe("with a tokenRefreshFunction", () => {
it("should emit logout and throw when token refresh fails", async () => {
const error = new Error("uh oh");
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
tokenRefreshFunction,
accessToken,
refreshToken,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should refresh token and retry request", async () => {
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
const tokenRefreshFunction = jest.fn().mockResolvedValue({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
const fetchFn = jest
.fn()
.mockResolvedValueOnce(unknownTokenResponse)
.mockResolvedValueOnce(okayResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
tokenRefreshFunction,
accessToken,
refreshToken,
});
const result = await api.authedRequest(Method.Post, "/account/password");
expect(result).toEqual(okayResponse);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(fetchFn).toHaveBeenCalledTimes(2);
// uses new access token
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
it("should only try to refresh the token once", async () => {
const newAccessToken = "new-access-token";
const newRefreshToken = "new-refresh-token";
const tokenRefreshFunction = jest.fn().mockResolvedValue({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
// fetch doesn't like our new or old tokens
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
jest.spyOn(emitter, "emit");
const api = new FetchHttpApi(emitter, {
baseUrl,
prefix,
fetchFn,
tokenRefreshFunction,
accessToken,
refreshToken,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
);
// tried to refresh the token once
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledTimes(2);
// uses new access token on retry
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
// logged out after refreshed access token is rejected
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
});
});
});
});
});
describe("getUrl()", () => {
@@ -300,22 +440,29 @@ describe("FetchHttpApi", () => {
jest.useFakeTimers();
const deferred = defer<Response>();
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
jest.spyOn(logger, "debug").mockImplementation(() => {});
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
const mockLogger = {
debug: jest.fn(),
} as unknown as Mocked<Logger>;
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn,
logger: mockLogger,
});
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
jest.advanceTimersByTime(1234);
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
await prom;
expect(logger.debug).not.toHaveBeenCalledWith("fragment");
expect(logger.debug).not.toHaveBeenCalledWith("query");
expect(logger.debug).not.toHaveBeenCalledWith("param");
expect(logger.debug).toHaveBeenCalledTimes(2);
expect(mocked(logger.debug).mock.calls[0]).toMatchInlineSnapshot(`
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
expect(mockLogger.debug).not.toHaveBeenCalledWith("param");
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
expect(mockLogger.debug.mock.calls[0]).toMatchInlineSnapshot(`
[
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
]
`);
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
expect(mockLogger.debug.mock.calls[1]).toMatchInlineSnapshot(`
[
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
]
+1
View File
@@ -328,6 +328,7 @@ describe("MatrixClient", function () {
"storeFilter",
"startup",
"deleteAllData",
"setUserCreator",
] as const
).reduce((r, k) => {
r[k] = jest.fn();
+1 -3
View File
@@ -29,9 +29,7 @@ const membershipTemplate: CallMembershipData = {
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: jest.fn().mockReturnValue(originTs),
sender: {
userId: "@alice:example.org",
},
getSender: jest.fn().mockReturnValue("@alice:example.org"),
} as unknown as MatrixEvent;
}
+63
View File
@@ -675,6 +675,69 @@ describe("Thread", () => {
});
});
});
describe("addEvent", () => {
describe("Given server support for threads", () => {
let previousThreadHasServerSideSupport: FeatureSupport;
beforeAll(() => {
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
Thread.hasServerSideSupport = FeatureSupport.Stable;
});
afterAll(() => {
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
});
it("Adds events even if they appear out of order", async () => {
// Given a thread exists
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
const prevNumEvents = thread.timeline.length;
// When two messages come in but the later one has an older timestamp
const message1 = createThreadMessage(thread.id, user, room, "message1");
const message2 = createThreadMessage(thread.id, user, room, "message2");
message2.localTimestamp -= 10000;
await thread.addEvent(message1, false);
await thread.addEvent(message2, false);
// Then both events end up in the timeline
expect(thread.timeline.length - prevNumEvents).toEqual(2);
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(message2);
expect(secondLastEvent).toBe(message1);
});
it("Adds events to start even if they appear out of order", async () => {
// Given a thread exists
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
const prevNumEvents = thread.timeline.length;
// When two messages come in but the later one has an older timestamp
const message1 = createThreadMessage(thread.id, user, room, "message1");
const message2 = createThreadMessage(thread.id, user, room, "message2");
message2.localTimestamp -= 10000;
await thread.addEvent(message1, false);
await thread.addEvent(message2, true);
// Then both events end up in the timeline
expect(thread.timeline.length - prevNumEvents).toEqual(2);
const lastEvent = thread.timeline.at(-1)!;
const firstEvent = thread.timeline.at(0)!;
expect(lastEvent).toBe(message1);
expect(firstEvent).toBe(message2);
});
});
});
});
/**
+21
View File
@@ -134,6 +134,25 @@ describe("oidc authorization", () => {
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
});
it("should generate url with create prompt", async () => {
const nonce = "abc123";
const metadata = delegatedAuthConfig.metadata;
const authUrl = new URL(
await generateOidcAuthorizationUrl({
metadata,
homeserverUrl: baseUrl,
clientId,
redirectUri: baseUrl,
nonce,
prompt: "create",
}),
);
expect(authUrl.searchParams.get("prompt")).toEqual("create");
});
});
describe("completeAuthorizationCodeGrant", () => {
@@ -284,6 +303,7 @@ describe("oidc authorization", () => {
expires_at: result.tokenResponse.expires_at,
scope,
},
idTokenClaims: result.idTokenClaims,
});
});
@@ -325,6 +345,7 @@ describe("oidc authorization", () => {
expires_at: result.tokenResponse.expires_at,
scope,
},
idTokenClaims: result.idTokenClaims,
});
expect(result.tokenResponse.token_type).toEqual("Bearer");
+270
View File
@@ -0,0 +1,270 @@
/**
* @jest-environment jsdom
*/
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("OidcTokenRefresher", () => {
// OidcTokenRefresher props
// see class declaration for info
const authConfig = {
issuer: "https://issuer.org/",
};
const clientId = "test-client-id";
const redirectUri = "https://test.org";
const deviceId = "abc123";
const idTokenClaims = {
exp: Date.now() / 1000 + 100000,
aud: clientId,
iss: authConfig.issuer,
sub: "123",
iat: 123,
};
// used to mock a valid token response, as consumed by OidcClient library
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
// auth config used in mocked calls to OP .well-known
const config = makeDelegatedAuthConfig(authConfig.issuer);
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
access_token: accessToken,
refresh_token: refreshToken,
token_type: "Bearer",
expires_in: 300,
scope: scope,
});
beforeEach(() => {
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
fetchMock.post(config.metadata.token_endpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("new-access-token", "new-refresh-token"),
});
});
afterEach(() => {
jest.restoreAllMocks();
fetchMock.resetBehavior();
});
it("throws when oidc client cannot be initialised", async () => {
jest.spyOn(logger, "error");
fetchMock.get(
`${config.metadata.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.",
// error from OidcClient
expect.any(Error),
);
});
it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly
expect(refresher.oidcClient.settings).toEqual(
expect.objectContaining({
client_id: clientId,
redirect_uri: redirectUri,
authority: authConfig.issuer,
scope,
}),
);
});
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.",
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token");
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
method: "POST",
});
expect(result).toEqual({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
});
});
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
await refresher.doRefreshAccessToken("refresh-token");
expect(refresher.persistTokens).toHaveBeenCalledWith({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
});
});
it("should only have one inflight refresh request at once", async () => {
fetchMock
.postOnce(
config.metadata.token_endpoint,
{
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
},
{ overwriteRoutes: true },
)
.postOnce(
config.metadata.token_endpoint,
{
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
},
{ overwriteRoutes: false },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.resetHistory();
const refreshToken = "refresh-token";
const first = refresher.doRefreshAccessToken(refreshToken);
const second = refresher.doRefreshAccessToken(refreshToken);
const result1 = await second;
const result2 = await first;
// only one call to token endpoint
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
expect(result1).toEqual({
accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token",
});
// same response
expect(result1).toEqual(result2);
// call again after first request resolves
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(third).toEqual({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
});
});
it("should log and rethrow when token refresh fails", async () => {
fetchMock.post(
config.metadata.token_endpoint,
{
status: 503,
headers: {
"Content-Type": "application/json",
},
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
});
it("should make fresh request after a failed request", async () => {
// make sure inflight request is cleared after a failure
fetchMock
.postOnce(
config.metadata.token_endpoint,
{
status: 503,
headers: {
"Content-Type": "application/json",
},
},
{ overwriteRoutes: true },
)
.postOnce(
config.metadata.token_endpoint,
{
status: 200,
headers: {
"Content-Type": "application/json",
},
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
},
{ overwriteRoutes: false },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.resetHistory();
// first call fails
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
// call again after first request resolves
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
// called token endpoint, got new tokens
expect(result).toEqual({
accessToken: "second-new-access-token",
refreshToken: "second-new-refresh-token",
});
});
});
});
+197 -1
View File
@@ -18,7 +18,7 @@ import MockHttpBackend from "matrix-mock-request";
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
import { MatrixClient } from "../../src/client";
import { EventType, MatrixEvent, Room } from "../../src/matrix";
import { EventType, MatrixEvent, RelationType, Room, threadIdForReceipt } from "../../src/matrix";
import { synthesizeReceipt } from "../../src/models/read-receipt";
import { encodeUri } from "../../src/utils";
import * as utils from "../test-utils/test-utils";
@@ -258,4 +258,200 @@ describe("Read receipt", () => {
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
});
});
describe("Determining the right thread ID for a receipt", () => {
it("provides the thread root ID for a normal threaded message", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: "!roomx",
content: {
"body": "Hello from a thread",
"m.relates_to": {
"event_id": "$thread1",
"m.in_reply_to": {
event_id: "$thread1",
},
"rel_type": "m.thread",
},
},
});
expect(threadIdForReceipt(event)).toEqual("$thread1");
});
it("provides 'main' for a non-thread message", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: "!roomx",
content: { body: "Hello" },
});
expect(threadIdForReceipt(event)).toEqual("main");
});
it("provides 'main' for a thread root", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: "!roomx",
content: { body: "Hello" },
});
// Set thread ID to this event's ID, meaning this is the thread root
event.setThreadId(event.getId());
expect(threadIdForReceipt(event)).toEqual("main");
});
it("provides 'main' for a reaction to a thread root", () => {
const event = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: "@bob:matrix.org",
room: "!roomx",
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: "$thread1",
key: Math.random().toString(),
},
},
});
// Set thread Id, meaning this looks like it's in the thread (this
// happens for relations like this, so that they appear in the
// thread's timeline).
event.setThreadId("$thread1");
// But because it's a reaction to the thread root, it's in main
expect(threadIdForReceipt(event)).toEqual("main");
});
it("provides the thread ID for a reaction to a threaded message", () => {
const event = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: "@bob:matrix.org",
room: "!roomx",
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: "$withinthread2",
key: Math.random().toString(),
},
},
});
// Set thread Id, to say this message is in the thread. This happens
// when the message arrived and is classified.
event.setThreadId("$thread1");
// It's in the thread because it refers to something else, not the
// thread root
expect(threadIdForReceipt(event)).toEqual("$thread1");
});
it("(suprisingly?) provides 'main' for a redaction of a threaded message", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomRedaction,
content: {
reason: "Spamming",
},
redacts: "$withinthread2",
room: "!roomx",
user: "@bob:matrix.org",
});
// Set thread Id, to say this message is in the thread.
event.setThreadId("$thread1");
// Because redacting a message removes all its m.relations, the
// message is no longer in the thread, so we must send a receipt for
// it in the main timeline.
//
// This is surprising, but it follows the spec (at least up to
// current latest room version, 11). In fact, the event should no
// longer have a thread ID set on it, so this testcase should not
// come up. (At time of writing, this is not the case though - it
// does still have threadId set.)
expect(threadIdForReceipt(event)).toEqual("main");
});
it("provides the thread ID for an edit of a threaded message", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomRedaction,
content: {
"body": "Edited!",
"m.new_content": {
body: "Edited!",
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: "$withinthread2",
},
},
room: "!roomx",
user: "@bob:matrix.org",
});
// Set thread Id, to say this message is in the thread.
event.setThreadId("$thread1");
// It's in the thread, because it redacts something inside the
// thread (not the thread root)
expect(threadIdForReceipt(event)).toEqual("$thread1");
});
it("provides 'main' for an edit of a thread root", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomRedaction,
content: {
"body": "Edited!",
"m.new_content": {
body: "Edited!",
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: "$thread1",
},
},
room: "!roomx",
user: "@bob:matrix.org",
});
// Set thread Id, to say this message is in the thread.
event.setThreadId("$thread1");
// It's in the thread, because it redacts something inside the
// thread (not the thread root)
expect(threadIdForReceipt(event)).toEqual("main");
});
it("provides 'main' for a redaction of the thread root", () => {
const event = utils.mkEvent({
event: true,
type: EventType.RoomRedaction,
content: {
reason: "Spamming",
},
redacts: "$thread1",
room: "!roomx",
user: "@bob:matrix.org",
});
// Set thread Id, to say this message is in the thread.
event.setThreadId("$thread1");
// It's in the thread, because it redacts something inside the
// thread (not the thread root)
expect(threadIdForReceipt(event)).toEqual("main");
});
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import "../../olm-loader";
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
import { decodeBase64 } from "../../../src/crypto/olmlib";
import { decodeBase64 } from "../../../src/base64";
import { DummyTransport } from "./DummyTransport";
function makeTransport(name: string) {
+1 -1
View File
@@ -29,7 +29,7 @@ import {
MSC3886SimpleHttpRendezvousTransportDetails,
} from "../../../src/rendezvous/transports";
import { DummyTransport } from "./DummyTransport";
import { decodeBase64 } from "../../../src/crypto/olmlib";
import { decodeBase64 } from "../../../src/base64";
import { logger } from "../../../src/logger";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
@@ -82,12 +82,6 @@ describe("OutgoingRequestProcessor", () => {
"https://example.com/_matrix/client/v3/keys/signatures/upload",
],
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
[
"SigningKeysUploadRequest",
SigningKeysUploadRequest,
"POST",
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
],
];
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
@@ -121,7 +115,7 @@ describe("OutgoingRequestProcessor", () => {
it("should handle ToDeviceRequests", async () => {
// first, mock up the ToDeviceRequest as we might expect to receive it from the Rust layer ...
const testBody = '{ "foo": "bar" }';
const testBody = '{ "messages": { "user": {"device": "bar" }}}';
const outgoingRequest = new ToDeviceRequest("1234", "test/type", "test/txnid", testBody);
// ... then poke it into the OutgoingRequestProcessor under test.
@@ -179,10 +173,37 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle SigningKeysUploadRequests without UIA", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new SigningKeysUploadRequest(JSON.stringify(testReq));
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request.
const testResponse = '{"result":1}';
httpBackend
.when("POST", "/_matrix")
.check((req) => {
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
expect(JSON.parse(req.rawData)).toEqual(testReq);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
await httpBackend.flushAllExpected();
await reqProm;
httpBackend.verifyNoOutstandingRequests();
});
it("should handle SigningKeysUploadRequests with UIA", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
const outgoingRequest = new SigningKeysUploadRequest(JSON.stringify(testReq));
// also create a UIA callback
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
@@ -192,7 +213,7 @@ describe("OutgoingRequestProcessor", () => {
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
// Now: check that it makes a matching HTTP request ...
// Now: check that it makes a matching HTTP request.
const testResponse = '{"result":1}';
httpBackend
.when("POST", "/_matrix")
@@ -204,12 +225,10 @@ describe("OutgoingRequestProcessor", () => {
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
await httpBackend.flushAllExpected();
await reqProm;
httpBackend.verifyNoOutstandingRequests();
});
@@ -0,0 +1,31 @@
/*
*
* 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 { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
import { HistoryVisibility } from "../../../src";
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
it.each([
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
});
+125 -4
View File
@@ -44,10 +44,12 @@ import {
EventShieldColour,
EventShieldReason,
ImportRoomKeysOpts,
KeyBackupCheck,
VerificationRequest,
} from "../../../src/crypto-api";
import * as testData from "../../test-utils/test-data";
import { defer } from "../../../src/utils";
import { logger } from "../../../src/logger";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
@@ -62,6 +64,9 @@ describe("initRustCrypto", () => {
return {
registerRoomKeyUpdatedCallback: jest.fn(),
registerUserIdentityUpdatedCallback: jest.fn(),
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
deleteSecretsFromInbox: jest.fn(),
registerReceiveSecretCallback: jest.fn(),
outgoingRequests: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
@@ -71,6 +76,7 @@ describe("initRustCrypto", () => {
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
await initRustCrypto(
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
@@ -93,6 +99,7 @@ describe("initRustCrypto", () => {
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
await initRustCrypto(
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
@@ -104,9 +111,35 @@ describe("initRustCrypto", () => {
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
});
it("Should get secrets from inbox on start", async () => {
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
await initRustCrypto(
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
"storePrefix",
"storePassphrase",
);
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
});
});
describe("RustCrypto", () => {
it("getVersion() should return the current version of the rust sdk and vodozemac", async () => {
const rustCrypto = await makeTestRustCrypto();
const versions = RustSdkCryptoJs.getVersions();
expect(rustCrypto.getVersion()).toBe(
`Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`,
);
});
describe(".importRoomKeys and .exportRoomKeys", () => {
let rustCrypto: RustCrypto;
@@ -315,6 +348,7 @@ describe("RustCrypto", () => {
} as unknown as Mocked<OutgoingRequestProcessor>;
rustCrypto = new RustCrypto(
logger,
olmMachine,
{} as MatrixHttpApi<any>,
TEST_USER,
@@ -444,6 +478,7 @@ describe("RustCrypto", () => {
getRoomEventEncryptionInfo: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -478,6 +513,26 @@ describe("RustCrypto", () => {
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
});
it("should handle decryption failures", async () => {
const event = mkEvent({
event: true,
type: "m.room.encrypted",
content: { algorithm: "fake_alg" },
room: "!room:id",
});
event.event.event_id = "$event:id";
const mockCryptoBackend = {
decryptEvent: () => {
throw new Error("UISI");
},
};
await event.attemptDecryption(mockCryptoBackend as unknown as CryptoBackend);
const res = await rustCrypto.getEncryptionInfoForEvent(event);
expect(res).toBe(null);
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
});
it("passes the event into the OlmMachine", async () => {
const encryptedEvent = await makeEncryptedEvent();
const res = await rustCrypto.getEncryptionInfoForEvent(encryptedEvent);
@@ -498,7 +553,7 @@ describe("RustCrypto", () => {
[RustSdkCryptoJs.ShieldColor.Red, EventShieldColour.RED],
])("gets the right shield color (%i)", async (rustShield, expectedShield) => {
const mockEncryptionInfo = {
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: null }),
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: undefined }),
} as unknown as RustSdkCryptoJs.EncryptionInfo;
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
@@ -509,7 +564,7 @@ describe("RustCrypto", () => {
});
it.each([
[null, null],
[undefined, null],
["Encrypted by an unverified user.", EventShieldReason.UNVERIFIED_IDENTITY],
["Encrypted by a device not verified by its owner.", EventShieldReason.UNSIGNED_DEVICE],
[
@@ -618,6 +673,7 @@ describe("RustCrypto", () => {
getDevice: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -783,10 +839,23 @@ describe("RustCrypto", () => {
it("can save and restore a key", async () => {
const key = "testtesttesttesttesttesttesttest";
const rustCrypto = await makeTestRustCrypto();
await rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key));
await rustCrypto.storeSessionBackupPrivateKey(
new TextEncoder().encode(key),
testData.SIGNED_BACKUP_DATA.version!,
);
const fetched = await rustCrypto.getSessionBackupPrivateKey();
expect(new TextDecoder().decode(fetched!)).toEqual(key);
});
it("fails to save a key if version not provided", async () => {
const key = "testtesttesttesttesttesttesttest";
const rustCrypto = await makeTestRustCrypto();
await expect(() => rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key))).rejects.toThrow(
"storeSessionBackupPrivateKey: version is required",
);
const fetched = await rustCrypto.getSessionBackupPrivateKey();
expect(fetched).toBeNull();
});
});
describe("getActiveSessionBackupVersion", () => {
@@ -823,6 +892,7 @@ describe("RustCrypto", () => {
getIdentity: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
logger,
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
@@ -850,6 +920,57 @@ describe("RustCrypto", () => {
expect(userVerificationStatus.wasCrossSigningVerified()).toBeFalsy();
});
});
describe("key backup", () => {
it("is started when rust crypto is created", async () => {
// `RustCrypto.checkKeyBackupAndEnable` async call is made in background in the RustCrypto constructor.
// We don't have an instance of the rust crypto yet, we spy directly in the prototype.
const spyCheckKeyBackupAndEnable = jest
.spyOn(RustCrypto.prototype, "checkKeyBackupAndEnable")
.mockResolvedValue({} as KeyBackupCheck);
await makeTestRustCrypto();
expect(spyCheckKeyBackupAndEnable).toHaveBeenCalled();
});
it("raises KeyBackupStatus event when identify change", async () => {
// Return the key backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
const olmMachine = {
getIdentity: jest.fn(),
// Force the backup to be trusted by the olmMachine
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(true) }),
isBackupEnabled: jest.fn().mockReturnValue(true),
getBackupKeys: jest.fn(),
enableBackupV1: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const rustCrypto = new RustCrypto(
logger,
olmMachine,
mockHttpApi,
testData.TEST_USER_ID,
testData.TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
// Wait for the key backup to be available
const keyBackupStatusPromise = new Promise<boolean>((resolve) =>
rustCrypto.once(CryptoEvent.KeyBackupStatus, resolve),
);
await rustCrypto.onUserIdentityUpdated(new RustSdkCryptoJs.UserId(testData.TEST_USER_ID));
expect(await keyBackupStatusPromise).toBe(true);
});
});
});
/** build a basic RustCrypto instance for testing
@@ -863,5 +984,5 @@ async function makeTestRustCrypto(
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
}
+58 -1
View File
@@ -16,8 +16,9 @@ limitations under the License.
import "fake-indexeddb/auto";
import "jest-localstorage-mock";
import { IDBFactory } from "fake-indexeddb";
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { defer } from "../../../src/utils";
@@ -76,6 +77,62 @@ describe("IndexedDBStore", () => {
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("Should load presence events on startup", async () => {
// 1. Create idb database
const indexedDB = new IDBFactory();
const setupDefer = defer<Event>();
const req = indexedDB.open("matrix-js-sdk:db3", 1);
let db: IDBDatabase;
req.onupgradeneeded = () => {
db = req.result;
db.createObjectStore("users", { keyPath: ["userId"] });
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = setupDefer.resolve;
await setupDefer.promise;
// 2. Fill in user presence data
const writeDefer = defer<Event>();
const transaction = db!.transaction(["users"], "readwrite");
const objectStore = transaction.objectStore("users");
const request = objectStore.put({
userId: "@alice:matrix.org",
event: {
content: {
presence: "online",
},
sender: "@alice:matrix.org",
type: "m.presence",
},
});
request.onsuccess = writeDefer.resolve;
await writeDefer.promise;
// 3. Close database
req.result.close();
// 2. Check if the code loads presence events
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "db3",
localStorage,
});
let userCreated = false;
let presenceEventEmitted = false;
store.setUserCreator((id: string) => {
userCreated = true;
const user = new User(id);
user.on(UserEvent.Presence, () => {
presenceEventEmitted = true;
});
return user;
});
await store.startup();
expect(userCreated).toBe(true);
expect(presenceEventEmitted).toBe(true);
});
it("should use MemoryStore methods for pending events if no localStorage", async () => {
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
+2 -2
View File
@@ -41,7 +41,7 @@ export enum AutoDiscoveryAction {
FAIL_ERROR = "FAIL_ERROR",
}
enum AutoDiscoveryError {
export enum AutoDiscoveryError {
Invalid = "Invalid homeserver discovery response",
GenericFailure = "Failed to get autodiscovery configuration from server",
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
@@ -114,7 +114,7 @@ export class AutoDiscovery {
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
/**
* The auto discovery failed. The client is expected to communicate
+79
View File
@@ -0,0 +1,79 @@
/*
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.
*/
/**
* Base64 encoding and decoding utilities
*/
/**
* Encode a typed array of uint8 as base64.
* @param uint8Array - The data to encode.
* @returns The base64.
*/
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
// A brief note on the state of base64 encoding in Javascript.
// As of 2023, there is still no common native impl between both browsers and
// node. Older Webpack provides an impl for Buffer and there is a polyfill class
// for it. There are also plenty of pure js impls, eg. base64-js which has 2336
// dependents at current count. Using this would probably be fine although it's
// a little under-docced and run by an individual. The node impl works fine,
// the browser impl works but predates Uint8Array and so only uses strings.
// Right now, switching between native (or polyfilled) impls like this feels
// like the least bad option, but... *shrugs*.
if (typeof Buffer === "function") {
return Buffer.from(uint8Array).toString("base64");
} else if (typeof btoa === "function" && uint8Array instanceof Uint8Array) {
// ArrayBuffer is a node concept so the param should always be a Uint8Array on
// the browser. We need to check because ArrayBuffers don't have reduce.
return btoa(uint8Array.reduce((acc, current) => acc + String.fromCharCode(current), ""));
} else {
throw new Error("No base64 impl found!");
}
}
/**
* Encode a typed array of uint8 as unpadded base64.
* @param uint8Array - The data to encode.
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
}
/**
* Decode a base64 string to a typed array of uint8.
* @param base64 - The base64 to decode.
* @returns The decoded data.
*/
export function decodeBase64(base64: string): Uint8Array {
// See encodeBase64 for a short treatise on base64 en/decoding in JS
if (typeof Buffer === "function") {
return Buffer.from(base64, "base64");
} else if (typeof atob === "function") {
const itFunc = function* (): Generator<number> {
const decoded = atob(
// built-in atob doesn't support base64url: convert so we support either
base64.replace("-", "+").replace("_", "/"),
);
for (let i = 0; i < decoded.length; ++i) {
yield decoded.charCodeAt(i);
}
};
return Uint8Array.from(itFunc());
} else {
throw new Error("No base64 impl found!");
}
}
+181 -78
View File
@@ -47,12 +47,12 @@ import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
import * as olmlib from "./crypto/olmlib";
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
import { decodeBase64, encodeBase64 } from "./base64";
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { TypedReEmitter } from "./ReEmitter";
import { IRoomEncryption, RoomList } from "./crypto/RoomList";
import { logger } from "./logger";
import { logger, Logger } from "./logger";
import { SERVICE_TYPES } from "./service-types";
import {
Body,
@@ -64,6 +64,7 @@ import {
IdentityPrefix,
IHttpOpts,
IRequestOpts,
TokenRefreshFunction,
MatrixError,
MatrixHttpApi,
MediaPrefix,
@@ -294,6 +295,14 @@ export interface ICreateClientOpts {
deviceId?: string;
accessToken?: string;
refreshToken?: string;
/**
* Function used to attempt refreshing access and refresh tokens
* Called by http-api when a possibly expired token is encountered
* and a refreshToken is found
*/
tokenRefreshFunction?: TokenRefreshFunction;
/**
* Identity server provider to retrieve the user's access token when accessing
@@ -415,6 +424,12 @@ export interface ICreateClientOpts {
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
*/
useLivekitForGroupCalls?: boolean;
/**
* A logger to associate with this MatrixClient.
* Defaults to the built-in global logger.
*/
logger?: Logger;
}
export interface IMatrixClientCreateOpts extends ICreateClientOpts {
@@ -1207,10 +1222,12 @@ const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action"
export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> {
public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY";
private readonly logger: Logger;
public reEmitter = new TypedReEmitter<EmittedEvents, ClientEventHandlerMap>(this);
public olmVersion: [number, number, number] | null = null; // populated after initCrypto
public usingExternalCrypto = false;
public store: Store;
private _store!: Store;
public deviceId: string | null;
public credentials: { userId: string | null };
@@ -1312,6 +1329,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public constructor(opts: IMatrixClientCreateOpts) {
super();
// If a custom logger is provided, use it. Otherwise, default to the global
// one in logger.ts.
this.logger = opts.logger ?? logger;
opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);
@@ -1332,26 +1353,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
baseUrl: opts.baseUrl,
idBaseUrl: opts.idBaseUrl,
accessToken: opts.accessToken,
refreshToken: opts.refreshToken,
tokenRefreshFunction: opts.tokenRefreshFunction,
prefix: ClientPrefix.V3,
onlyData: true,
extraParams: opts.queryParams,
localTimeoutMs: opts.localTimeoutMs,
useAuthorizationHeader: opts.useAuthorizationHeader,
logger: this.logger,
});
if (opts.deviceToImport) {
if (this.deviceId) {
logger.warn(
this.logger.warn(
"not importing device because device ID is provided to " +
"constructor independently of exported data",
);
} else if (this.credentials.userId) {
logger.warn(
this.logger.warn(
"not importing device because user ID is provided to " +
"constructor independently of exported data",
);
} else if (!opts.deviceToImport.deviceId) {
logger.warn("not importing device because no device ID in exported data");
this.logger.warn("not importing device because no device ID in exported data");
} else {
this.deviceId = opts.deviceToImport.deviceId;
this.credentials.userId = opts.deviceToImport.userId;
@@ -1482,6 +1506,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
}
public set store(newStore: Store) {
this._store = newStore;
this._store.setUserCreator((userId) => User.createUser(userId, this));
}
public get store(): Store {
return this._store;
}
/**
* High level helper method to begin syncing and poll for new events. To listen for these
* events, add a listener for {@link ClientEvent.Event}
@@ -1523,7 +1556,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.syncApi) {
// This shouldn't happen since we thought the client was not running
logger.error("Still have sync object whilst not running: stopping old one");
this.logger.error("Still have sync object whilst not running: stopping old one");
this.syncApi.stop();
}
@@ -1537,7 +1570,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
Thread.setServerSideListSupport(list);
Thread.setServerSideFwdPaginationSupport(fwdPagination);
} catch (e) {
logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
this.logger.error(
"Can't fetch server versions, continuing to initialise sync, this will be retried later",
e,
);
}
this.clientOpts = opts ?? {};
@@ -1553,7 +1589,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
this.logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
}
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
@@ -1565,7 +1601,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
}
this.syncApi.sync().catch((e) => logger.info("Sync startup aborted with an error:", e));
this.syncApi.sync().catch((e) => this.logger.info("Sync startup aborted with an error:", e));
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
this.clientWellKnownIntervalID = setInterval(() => {
@@ -1604,7 +1640,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!this.clientRunning) return; // already stopped
logger.log("stopping MatrixClient");
this.logger.debug("stopping MatrixClient");
this.clientRunning = false;
@@ -1654,7 +1690,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (!getDeviceResult.device_data || !getDeviceResult.device_id) {
logger.info("no dehydrated device found");
this.logger.info("no dehydrated device found");
return;
}
@@ -1662,16 +1698,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
try {
const deviceData = getDeviceResult.device_data;
if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) {
logger.warn("Wrong algorithm for dehydrated device");
this.logger.warn("Wrong algorithm for dehydrated device");
return;
}
logger.log("unpickling dehydrated device");
this.logger.debug("unpickling dehydrated device");
const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => {
// copy the key so that it doesn't get clobbered
account.unpickle(new Uint8Array(k), deviceData.account);
});
account.unpickle(key, deviceData.account);
logger.log("unpickled device");
this.logger.debug("unpickled device");
const rehydrateResult = await this.http.authedRequest<{ success: boolean }>(
Method.Post,
@@ -1687,7 +1723,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (rehydrateResult.success) {
this.deviceId = getDeviceResult.device_id;
logger.info("using dehydrated device");
this.logger.info("using dehydrated device");
const pickleKey = this.pickleKey || "DEFAULT_KEY";
this.exportedOlmDeviceToImport = {
pickledAccount: account.pickle(pickleKey),
@@ -1698,12 +1734,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.deviceId;
} else {
account.free();
logger.info("not using dehydrated device");
this.logger.info("not using dehydrated device");
return;
}
} catch (e) {
account.free();
logger.warn("could not unpickle", e);
this.logger.warn("could not unpickle", e);
}
}
@@ -1723,7 +1759,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
},
);
} catch (e) {
logger.info("could not get dehydrated device", e);
this.logger.info("could not get dehydrated device", e);
return;
}
}
@@ -1745,7 +1781,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
deviceDisplayName?: string,
): Promise<void> {
if (!this.crypto) {
logger.warn("not dehydrating device if crypto is not enabled");
this.logger.warn("not dehydrating device if crypto is not enabled");
return;
}
return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName);
@@ -1766,7 +1802,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
deviceDisplayName?: string,
): Promise<string | undefined> {
if (!this.crypto) {
logger.warn("not dehydrating device if crypto is not enabled");
this.logger.warn("not dehydrating device if crypto is not enabled");
return;
}
await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName);
@@ -1775,7 +1811,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async exportDevice(): Promise<IExportedDevice | undefined> {
if (!this.crypto) {
logger.warn("not exporting device if crypto is not enabled");
this.logger.warn("not exporting device if crypto is not enabled");
return;
}
return {
@@ -1818,10 +1854,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
]) {
const prom = new Promise((resolve, reject) => {
logger.info(`Removing IndexedDB instance ${dbname}`);
this.logger.info(`Removing IndexedDB instance ${dbname}`);
const req = indexedDB.deleteDatabase(dbname);
req.onsuccess = (_): void => {
logger.info(`Removed IndexedDB instance ${dbname}`);
this.logger.info(`Removed IndexedDB instance ${dbname}`);
resolve(0);
};
req.onerror = (e): void => {
@@ -1830,11 +1866,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// database that did not allow mutations."
//
// it seems like the only thing we can really do is ignore the error.
logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
this.logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
resolve(0);
};
req.onblocked = (e): void => {
logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
this.logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
};
});
await prom;
@@ -2141,7 +2177,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.cachedCapabilities && !fresh) {
if (now < this.cachedCapabilities.expiration) {
logger.log("Returning cached capabilities");
this.logger.debug("Returning cached capabilities");
return Promise.resolve(this.cachedCapabilities.capabilities);
}
}
@@ -2153,7 +2189,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
.authedRequest<Response>(Method.Get, "/capabilities")
.catch((e: Error): Response => {
// We swallow errors because we need a default object anyhow
logger.error(e);
this.logger.error(e);
return {};
})
.then((r = {}) => {
@@ -2168,7 +2204,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
expiration: now + cacheMs,
};
logger.log("Caching capabilities: ", capabilities);
this.logger.debug("Caching capabilities: ", capabilities);
return capabilities;
});
}
@@ -2191,7 +2227,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (this.cryptoBackend) {
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return;
}
@@ -2200,11 +2236,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error(`Cannot enable encryption: no cryptoStore provided`);
}
logger.log("Crypto: Starting up crypto store...");
this.logger.debug("Crypto: Starting up crypto store...");
await this.cryptoStore.startup();
// initialise the list of encrypted rooms (whether or not crypto is enabled)
logger.log("Crypto: initialising roomlist...");
this.logger.debug("Crypto: initialising roomlist...");
await this.roomList.init();
const userId = this.getUserId();
@@ -2244,7 +2280,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
CryptoEvent.KeysChanged,
]);
logger.log("Crypto: initialising crypto object...");
this.logger.debug("Crypto: initialising crypto object...");
await crypto.init({
exportedOlmDevice: this.exportedOlmDeviceToImport,
pickleKey: this.pickleKey,
@@ -2260,7 +2296,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// upload our keys in the background
this.crypto.uploadDeviceKeys().catch((e) => {
// TODO: throwing away this error is a really bad idea.
logger.error("Error uploading device keys", e);
this.logger.error("Error uploading device keys", e);
});
}
@@ -2281,7 +2317,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async initRustCrypto({ useIndexedDB = true }: { useIndexedDB?: boolean } = {}): Promise<void> {
if (this.cryptoBackend) {
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return;
}
@@ -2304,6 +2340,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// needed.
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(
this.logger,
this.http,
userId,
deviceId,
@@ -2382,7 +2419,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @deprecated Does nothing.
*/
public async uploadKeys(): Promise<void> {
logger.warn("MatrixClient.uploadKeys is deprecated");
this.logger.warn("MatrixClient.uploadKeys is deprecated");
}
/**
@@ -2651,12 +2688,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* This API is currently UNSTABLE and may change or be removed without notice.
*
* It has no effect with the Rust crypto implementation.
*
* @param value - whether error on unknown devices
*
* @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
*
* ```ts
* client.getCrypto().globalBlacklistUnverifiedDevices = value;
* client.getCrypto().globalErrorOnUnknownDevices = value;
* ```
*/
public setGlobalErrorOnUnknownDevices(value: boolean): void {
@@ -3462,7 +3499,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (opts.secureSecretStorage) {
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
logger.info("Key backup private key stored in secret storage");
this.logger.info("Key backup private key stored in secret storage");
}
return {
@@ -3531,7 +3568,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// sessions.
await this.checkKeyBackup();
if (!this.getKeyBackupEnabled()) {
logger.error("Key backup not usable even though we just created it");
this.logger.error("Key backup not usable even though we just created it");
}
return res;
@@ -3864,6 +3901,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("End-to-end encryption disabled");
}
if (!backupInfo.version) {
throw new Error("Backup version must be defined");
}
let totalKeyCount = 0;
let keys: IMegolmSessionData[] = [];
@@ -3881,9 +3922,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Cache the key, if possible.
// This is async.
this.cryptoBackend
.storeSessionBackupPrivateKey(privKey)
.storeSessionBackupPrivateKey(privKey, backupInfo.version)
.catch((e) => {
logger.warn("Error caching session backup key:", e);
this.logger.warn("Error caching session backup key:", e);
})
.then(cacheCompleteCallback);
@@ -3930,7 +3971,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
key.session_id = targetSessionId!;
keys.push(key);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e);
this.logger.debug("Failed to decrypt megolm session from backup", e);
}
}
} finally {
@@ -3976,7 +4017,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomEncryption = this.roomList.getRoomEncryption(roomId);
if (!roomEncryption) {
// unknown room, or unencrypted room
logger.error("Unknown room. Not sharing decryption keys");
this.logger.error("Unknown room. Not sharing decryption keys");
return;
}
@@ -3991,7 +4032,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (alg.sendSharedHistoryInboundSessions) {
await alg.sendSharedHistoryInboundSessions(devicesByUser);
} else {
logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
this.logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
}
}
@@ -4574,7 +4615,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const type = localEvent.getType();
logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
this.logger.debug(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
localEvent.setTxnId(txnId);
localEvent.setStatus(EventStatus.SENDING);
@@ -4646,7 +4687,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return promise;
})
.catch((err) => {
logger.error("Error sending event", err.stack || err);
this.logger.error("Error sending event", err.stack || err);
try {
// set the error on the event before we update the status:
// updating the status emits the event, so the state should be
@@ -4654,7 +4695,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
event.error = err;
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
} catch (e) {
logger.error("Exception in error handler!", (<Error>e).stack || err);
this.logger.error("Exception in error handler!", (<Error>e).stack || err);
}
if (err instanceof MatrixError) {
err.event = event;
@@ -4767,7 +4808,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http
.authedRequest<ISendEventResponse>(Method.Put, path, undefined, event.getWireContent())
.then((res) => {
logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
return res;
});
}
@@ -5125,24 +5166,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: event.getId()!,
});
if (!unthreaded && this.supportsThreads()) {
// XXX: the spec currently says a threaded read receipt can be sent for the root of a thread,
// but in practice this isn't possible and the spec needs updating.
const isThread =
!!event.threadRootId &&
// A thread cannot be just a thread root and a thread root can only be read in the main timeline
!event.isThreadRoot &&
// Similarly non-thread relations upon the thread root (reactions, edits) should also be for the main timeline.
event.isRelation() &&
(event.isRelation(THREAD_RELATION_TYPE.name) || event.relationEventId !== event.threadRootId);
body = {
...body,
// Only thread replies should define a specific thread. Thread roots can only be read in the main timeline.
thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE,
};
}
// Unless we're explicitly making an unthreaded receipt or we don't
// support threads, include the `thread_id` property in the body.
const shouldAddThreadId = !unthreaded && this.supportsThreads();
const fullBody = shouldAddThreadId ? { ...body, thread_id: threadIdForReceipt(event) } : body;
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, fullBody || {});
const room = this.getRoom(event.getRoomId());
if (room && this.credentials.userId) {
@@ -5867,7 +5896,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const mapper = this.getEventMapper();
const event = mapper(res.event);
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
logger.warn("Tried loading a regular timeline at the position of a thread event");
this.logger.warn("Tried loading a regular timeline at the position of a thread event");
return undefined;
}
const events = [
@@ -7004,7 +7033,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// cleanup locks
this.syncLeftRoomsPromise
.then(() => {
logger.log("Marking success of sync left room request");
this.logger.debug("Marking success of sync left room request");
this.syncedLeftRooms = true; // flip the bit on success
})
.finally(() => {
@@ -7209,14 +7238,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
let credentialsGood = false;
const remainingTime = this.turnServersExpiry - Date.now();
if (remainingTime > TURN_CHECK_INTERVAL) {
logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
this.logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
credentialsGood = true;
} else {
logger.debug("Fetching new TURN credentials");
this.logger.debug("Fetching new TURN credentials");
try {
const res = await this.turnServer();
if (res.uris) {
logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
this.logger.debug("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
// map the response to a format that can be fed to RTCPeerConnection
const servers: ITurnServer = {
urls: res.uris,
@@ -7230,10 +7259,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.emit(ClientEvent.TurnServers, this.turnServers);
}
} catch (err) {
logger.error("Failed to get TURN URIs", err);
this.logger.error("Failed to get TURN URIs", err);
if ((<HTTPError>err).httpStatus === 403) {
// We got a 403, so there's no point in looping forever.
logger.info("TURN access unavailable for this account: stopping credentials checks");
this.logger.info("TURN access unavailable for this account: stopping credentials checks");
if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID);
this.checkTurnServersIntervalID = undefined;
this.emit(ClientEvent.TurnServersError, <HTTPError>err, true); // fatal
@@ -7695,6 +7724,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.opts.accessToken || null;
}
/**
* Get the refresh token associated with this account.
* @returns The refresh_token or null
*/
public getRefreshToken(): string | null {
return this.http.opts.refreshToken ?? null;
}
/**
* Set the access token associated with this account.
* @param token - The new access token.
@@ -7972,7 +8009,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
try {
while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0);
} catch (err) {
logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err);
this.logger.error(
"Key backup request failed when logging out. Some keys may be missing from backup",
err,
);
}
}
@@ -8133,7 +8173,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
templatedUrl += "/$eventType";
}
} else if (eventType !== null) {
logger.warn(`eventType: ${eventType} ignored when fetching
this.logger.warn(`eventType: ${eventType} ignored when fetching
relations as relationType is null`);
eventType = null;
}
@@ -9425,7 +9465,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
targets.set(userId, Array.from(deviceMessages.keys()));
}
logger.log(`PUT ${path}`, targets);
this.logger.debug(`PUT ${path}`, targets);
return this.http.authedRequest(Method.Put, path, undefined, body);
}
@@ -9684,7 +9724,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @deprecated use supportsThreads() instead
*/
public supportsExperimentalThreads(): boolean {
logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
this.logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
return this.clientOpts?.experimentalThreadSupport || false;
}
@@ -9882,3 +9922,66 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
}
}
}
/**
* Given an event, figure out the thread ID we should use for it in a receipt.
*
* This will either be "main", or event.threadRootId. For the thread root, or
* e.g. reactions to the thread root, this will be main. For events inside the
* thread, or e.g. reactions to them, this will be event.threadRootId.
*
* (Exported for test.)
*/
export function threadIdForReceipt(event: MatrixEvent): string {
return inMainTimelineForReceipt(event) ? MAIN_ROOM_TIMELINE : event.threadRootId!;
}
/**
* a) True for non-threaded messages, thread roots and non-thread relations to thread roots.
* b) False for messages with thread relations to the thread root.
* c) False for messages with any kind of relation to a message from case b.
*
* Note: true for redactions of messages that are in threads. Redacted messages
* are not really in threads (because their relations are gone), so if they look
* like they are in threads, that is a sign of a bug elsewhere. (At time of
* writing, this bug definitely exists - messages are not moved to another
* thread when they are redacted.)
*
* @returns true if this event is considered to be in the main timeline as far
* as receipts are concerned.
*/
function inMainTimelineForReceipt(event: MatrixEvent): boolean {
if (!event.threadRootId) {
// Not in a thread: then it is in the main timeline
return true;
}
if (event.isThreadRoot) {
// Thread roots are in the main timeline. Note: the spec is ambiguous (or
// wrong) on this - see
// https://github.com/matrix-org/matrix-spec-proposals/pull/4037
return true;
}
if (!event.isRelation()) {
// If it's not related to anything, it can't be related via a chain of
// relations to a thread root.
//
// Note: this is a bug, because how does it have a threadRootId if it is
// neither a thread root, nor related to one?
logger.warn(`Event is not a relation or a thread root, but still has a threadRootId! id=${event.getId()}`);
return true;
}
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
// It's a message in a thread - definitely not in the main timeline.
return false;
}
const isRelatedToRoot = event.relationEventId === event.threadRootId;
// If it's related to the thread root (and we already know it's not a thread
// relation) then it's in the main timeline. If it's related to something
// else, then it's in the thread (because it has a thread ID).
return isRelatedToRoot;
}
+2 -2
View File
@@ -35,8 +35,8 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* 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.
* @remarks This has no effect in Rust Crypto; it exists only for the sake of
* the accessors in MatrixClient.
*/
globalErrorOnUnknownDevices: boolean;
-46
View File
@@ -1,46 +0,0 @@
/*
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.
*/
/**
* Base64 encoding and decoding utility for crypo.
*/
/**
* Encode a typed array of uint8 as base64.
* @param uint8Array - The data to encode.
* @returns The base64.
*/
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return Buffer.from(uint8Array).toString("base64");
}
/**
* Encode a typed array of uint8 as unpadded base64.
* @param uint8Array - The data to encode.
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
}
/**
* Decode a base64 string to a typed array of uint8.
* @param base64 - The base64 to decode.
* @returns The decoded data.
*/
export function decodeBase64(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
+21
View File
@@ -39,6 +39,13 @@ export interface CryptoApi {
*/
globalBlacklistUnverifiedDevices: boolean;
/**
* Return the current version of the crypto module.
* For example: `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`.
* @returns the formatted version
*/
getVersion(): string;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
@@ -372,11 +379,25 @@ export interface CryptoApi {
* Store the backup decryption key.
*
* This should be called if the client has received the key from another device via secret sharing (gossiping).
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
*
* @param key - the backup decryption key
*
* @deprecated prefer the variant with a `version` parameter.
*/
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
/**
* Store the backup decryption key.
*
* This should be called if the client has received the key from another device via secret sharing (gossiping).
* It is the responsability of the caller to check that the decryption key is valid for the given backup version.
*
* @param key - the backup decryption key
* @param version - the backup version corresponding to this decryption key
*/
storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise<void>;
/**
* Get the current status of key backup.
*
+15 -3
View File
@@ -224,13 +224,25 @@ export enum VerificationPhase {
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
Ready,
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
/**
* The verification is in flight.
*
* This means that an `m.key.verification.start` event has been sent or received, choosing a verification method;
* however the verification has not yet completed or been cancelled.
*/
Started,
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
/**
* An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling
* the verification request
*/
Cancelled,
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
/**
* The verification request is complete.
*
* Normally this means that `m.key.verification.done` events have been sent and received.
*/
Done,
}
+2 -1
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import type { PkSigning } from "@matrix-org/olm";
import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
import { IObject, pkSign, pkVerify } from "./olmlib";
import { logger } from "../logger";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
@@ -31,6 +31,7 @@ import { ISignatures } from "../@types/signed";
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
import { DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
import { decodeBase64, encodeBase64 } from "../base64";
// backwards-compatibility re-exports
export { UserTrustLevel };
+15 -15
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm";
import { logger, PrefixedLogger } from "../logger";
import { logger, Logger } from "../logger";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import * as algorithms from "./algorithms";
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
@@ -531,7 +531,7 @@ export class OlmDevice {
}
});
},
logger.withPrefix("[createOutboundSession]"),
logger.getChild("[createOutboundSession]"),
);
return newSessionId!;
}
@@ -588,7 +588,7 @@ export class OlmDevice {
}
});
},
logger.withPrefix("[createInboundSession]"),
logger.getChild("[createInboundSession]"),
);
return result!;
@@ -602,7 +602,7 @@ export class OlmDevice {
* @returns a list of known session ids for the device
*/
public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> {
const log = logger.withPrefix("[getSessionIdsForDevice]");
const log = logger.getChild("[getSessionIdsForDevice]");
if (theirDeviceIdentityKey in this.sessionsInProgress) {
log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`);
@@ -642,7 +642,7 @@ export class OlmDevice {
public async getSessionIdForDevice(
theirDeviceIdentityKey: string,
nowait = false,
log?: PrefixedLogger,
log?: Logger,
): Promise<string | null> {
const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log);
@@ -686,9 +686,9 @@ export class OlmDevice {
public async getSessionInfoForDevice(
deviceIdentityKey: string,
nowait = false,
log = logger,
log: Logger = logger,
): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> {
log = log.withPrefix("[getSessionInfoForDevice]");
log = log.getChild("[getSessionInfoForDevice]");
if (deviceIdentityKey in this.sessionsInProgress && !nowait) {
log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`);
@@ -764,7 +764,7 @@ export class OlmDevice {
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
});
},
logger.withPrefix("[encryptMessage]"),
logger.getChild("[encryptMessage]"),
);
return res!;
}
@@ -806,7 +806,7 @@ export class OlmDevice {
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
});
},
logger.withPrefix("[decryptMessage]"),
logger.getChild("[decryptMessage]"),
);
return payloadString!;
}
@@ -842,7 +842,7 @@ export class OlmDevice {
matches = sessionInfo.session.matches_inbound(ciphertext);
});
},
logger.withPrefix("[matchesSession]"),
logger.getChild("[matchesSession]"),
);
return matches!;
}
@@ -1142,7 +1142,7 @@ export class OlmDevice {
},
);
},
logger.withPrefix("[addInboundGroupSession]"),
logger.getChild("[addInboundGroupSession]"),
);
}
@@ -1282,7 +1282,7 @@ export class OlmDevice {
};
});
},
logger.withPrefix("[decryptGroupMessage]"),
logger.getChild("[decryptGroupMessage]"),
);
if (error!) {
@@ -1328,7 +1328,7 @@ export class OlmDevice {
}
});
},
logger.withPrefix("[hasInboundSessionKeys]"),
logger.getChild("[hasInboundSessionKeys]"),
);
return result!;
@@ -1398,7 +1398,7 @@ export class OlmDevice {
};
});
},
logger.withPrefix("[getInboundGroupSessionKey]"),
logger.getChild("[getInboundGroupSessionKey]"),
);
return result;
@@ -1443,7 +1443,7 @@ export class OlmDevice {
(txn) => {
result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn);
},
logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"),
logger.getChild("[getSharedHistoryInboundGroupSessionsForRoom]"),
);
return result!;
}
+1 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { decodeBase64, encodeBase64 } from "./olmlib";
import { decodeBase64, encodeBase64 } from "../base64";
import { subtleCrypto, crypto, TextEncoder } from "./crypto";
// salt for HKDF, with 8 bytes of zeros
+18 -18
View File
@@ -21,7 +21,7 @@ limitations under the License.
import { v4 as uuidv4 } from "uuid";
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
import { logger, PrefixedLogger } from "../../logger";
import { logger, Logger } from "../../logger";
import * as olmlib from "../olmlib";
import {
DecryptionAlgorithm,
@@ -246,12 +246,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
};
protected readonly roomId: string;
private readonly prefixedLogger: PrefixedLogger;
private readonly prefixedLogger: Logger;
public constructor(params: IParams & Required<Pick<IParams, "roomId">>) {
super(params);
this.roomId = params.roomId;
this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`);
this.prefixedLogger = logger.getChild(`[${this.roomId} encryption]`);
this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100;
this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000;
@@ -333,7 +333,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
// need to make a brand new session?
if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
this.prefixedLogger.log("Starting new megolm session because we need to rotate.");
this.prefixedLogger.debug("Starting new megolm session because we need to rotate.");
session = null;
}
@@ -343,9 +343,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
}
if (!session) {
this.prefixedLogger.log("Starting new megolm session");
this.prefixedLogger.debug("Starting new megolm session");
session = await this.prepareNewSession(sharedHistory);
this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`);
this.prefixedLogger.debug(`Started new megolm session ${session.sessionId}`);
this.outboundSessions[session.sessionId] = session;
}
@@ -968,12 +968,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
for (let i = 0; i < userDeviceMaps.length; i++) {
try {
await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload);
this.prefixedLogger.log(
this.prefixedLogger.debug(
`Completed blacklist notification for ${session.sessionId} ` +
`(slice ${i + 1}/${userDeviceMaps.length})`,
);
} catch (e) {
this.prefixedLogger.log(
this.prefixedLogger.debug(
`blacklist notification for ${session.sessionId} ` +
`(slice ${i + 1}/${userDeviceMaps.length}) failed`,
);
@@ -1054,7 +1054,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
* @returns Promise which resolves to the new event body
*/
public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> {
this.prefixedLogger.log("Starting to encrypt event");
this.prefixedLogger.debug("Starting to encrypt event");
if (this.encryptionPreparation != null) {
// If we started sending keys, wait for it to be done.
@@ -1291,12 +1291,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
private olmlib = olmlib;
protected readonly roomId: string;
private readonly prefixedLogger: PrefixedLogger;
private readonly prefixedLogger: Logger;
public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) {
super(params);
this.roomId = params.roomId;
this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`);
this.prefixedLogger = logger.getChild(`[${this.roomId} decryption]`);
}
/**
@@ -1740,7 +1740,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
"readwrite",
["parked_shared_history"],
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn),
logger.withPrefix("[addParkedSharedHistory]"),
logger.getChild("[addParkedSharedHistory]"),
);
}
@@ -1955,7 +1955,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
return null;
}
this.prefixedLogger.log(
this.prefixedLogger.debug(
"sharing keys for session " +
body.sender_key +
"|" +
@@ -2051,7 +2051,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.
this.prefixedLogger.log("Failed to back up megolm session", e);
this.prefixedLogger.debug("Failed to back up megolm session", e);
});
}
// have another go at decrypting events sent with this session.
@@ -2135,7 +2135,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
this.prefixedLogger.log(
this.prefixedLogger.debug(
`Sharing history in with users ${Array.from(devicesByUser.keys())}`,
sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
);
@@ -2178,20 +2178,20 @@ export class MegolmDecryption extends DecryptionAlgorithm {
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");
this.prefixedLogger.debug("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
deviceMessages.delete(deviceId);
}
}
// No devices left for that user? Strip that too.
if (deviceMessages.size === 0) {
this.prefixedLogger.log("Pruned all devices for user " + userId);
this.prefixedLogger.debug("Pruned all devices for user " + userId);
contentMap.delete(userId);
}
}
// Is there anything left?
if (contentMap.size === 0) {
this.prefixedLogger.log("No users left to send to: aborting");
this.prefixedLogger.debug("No users left to send to: aborting");
return;
}
+1 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import anotherjson from "another-json";
import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto";
import { decodeBase64, encodeBase64 } from "./olmlib";
import { decodeBase64, encodeBase64 } from "../base64";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
import { logger } from "../logger";
+20 -11
View File
@@ -102,6 +102,7 @@ import {
import { Device, DeviceMap } from "../models/device";
import { deviceInfoToDevice } from "./device-converter";
import { ClientPrefix, MatrixError, Method } from "../http-api";
import { decodeBase64, encodeBase64 } from "../base64";
/* re-exports for backwards compatibility */
export type {
@@ -500,7 +501,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
}
return olmlib.decodeBase64(fixedKey || storedKey);
return decodeBase64(fixedKey || storedKey);
}
// try to get key from app
@@ -609,6 +610,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
this.backupManager.checkAndStart();
}
/**
* Implementation of {@link CryptoApi#getVersion}.
*/
public getVersion(): string {
const olmVersionTuple = Crypto.getOlmVersion();
return `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`;
}
/**
* Whether to trust a others users signatures of their devices.
* If false, devices will only be considered 'verified' if we have
@@ -1050,7 +1059,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
newKeyId = await createSSSS(opts, backupKey);
// store the backup key in secret storage
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey!), [newKeyId]);
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]);
// The backup is trusted because the user provided the private key.
// Sign the backup with the cross-signing key so the key backup can
@@ -1094,7 +1103,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
);
// write the key to 4S
const privateKey = decodeRecoveryKey(info.recovery_key);
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
await secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
// create keyBackupInfo object to add to builder
const data: IKeyBackupInfo = {
@@ -1122,7 +1131,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const keyId = newKeyId || oldKeyId;
await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null);
}
const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey));
const decodedBackupKey = new Uint8Array(decodeBase64(fixedBackupKey || sessionBackupKey));
builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
} else if (this.backupManager.getKeyBackupEnabled()) {
// key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
@@ -1137,7 +1146,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return;
}
logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS");
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey));
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey));
}
const operation = builder.buildOperation();
@@ -1178,7 +1187,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// write the key to 4S
const privateKey = info.privateKey;
await this.secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
await this.storeSessionBackupPrivateKey(privateKey);
await this.backupManager.checkAndStart();
@@ -1301,13 +1310,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// make sure we have a Uint8Array, rather than a string
if (typeof encodedKey === "string") {
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(encodedKey) || encodedKey));
key = new Uint8Array(decodeBase64(fixBackupKey(encodedKey) || encodedKey));
await this.storeSessionBackupPrivateKey(key);
}
if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) {
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
const decrypted = await decryptAES(encodedKey, pickleKey, "m.megolm_backup.v1");
key = olmlib.decodeBase64(decrypted);
key = decodeBase64(decrypted);
}
return key;
}
@@ -1317,13 +1326,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param key - the private key
* @returns a promise so you can catch failures
*/
public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
public async storeSessionBackupPrivateKey(key: ArrayLike<number>, version?: string): Promise<void> {
if (!(key instanceof Uint8Array)) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
}
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1");
const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, "m.megolm_backup.v1");
return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey);
});
@@ -4196,7 +4205,7 @@ export function fixBackupKey(key?: string): string | null {
return null;
}
const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x));
return olmlib.encodeBase64(fixedKey);
return encodeBase64(fixedKey);
}
/**
+3 -30
View File
@@ -24,7 +24,7 @@ import type { PkSigning } from "@matrix-org/olm";
import type { IOneTimeKey } from "../@types/crypto";
import { OlmDevice } from "./OlmDevice";
import { DeviceInfo } from "./deviceinfo";
import { logger } from "../logger";
import { Logger, logger } from "../logger";
import { IClaimOTKsResult, MatrixClient } from "../client";
import { ISignatures } from "../@types/signed";
import { MatrixEvent } from "../models/event";
@@ -215,7 +215,7 @@ export async function ensureOlmSessionsForDevices(
force = false,
otkTimeout?: number,
failedServers?: string[],
log = logger,
log: Logger = logger,
): Promise<Map<string, Map<string, IOlmSessionResult>>> {
const devicesWithoutSession: [string, string][] = [
// [userId, deviceId], ...
@@ -319,7 +319,7 @@ export async function ensureOlmSessionsForDevices(
for (const resolver of resolveSession.values()) {
resolver();
}
log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
log.debug(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
throw e;
}
@@ -537,30 +537,3 @@ export function isOlmEncrypted(event: MatrixEvent): boolean {
}
return true;
}
/**
* Encode a typed array of uint8 as base64.
* @param uint8Array - The data to encode.
* @returns The base64.
*/
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return Buffer.from(uint8Array).toString("base64");
}
/**
* Encode a typed array of uint8 as unpadded base64.
* @param uint8Array - The data to encode.
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeBase64(uint8Array).replace(/=+$/g, "");
}
/**
* Decode a base64 string to a typed array of uint8.
* @param base64 - The base64 to decode.
* @returns The decoded data.
*/
export function decodeBase64(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
+2 -2
View File
@@ -22,7 +22,7 @@ import { TrackingStatus } from "../DeviceList";
import { IRoomEncryption } from "../RoomList";
import { IDevice } from "../deviceinfo";
import { ICrossSigningInfo } from "../CrossSigning";
import { PrefixedLogger } from "../../logger";
import { Logger } from "../../logger";
import { InboundGroupSessionData } from "../OlmDevice";
import { MatrixEvent } from "../../models/event";
import { DehydrationManager } from "../dehydration";
@@ -144,7 +144,7 @@ export interface CryptoStore {
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): Promise<T>;
}
export type Mode = "readonly" | "readwrite";
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger, PrefixedLogger } from "../../logger";
import { Logger, logger } from "../../logger";
import { deepCompare } from "../../utils";
import {
CryptoStore,
@@ -906,7 +906,7 @@ export class Backend implements CryptoStore {
mode: Mode,
stores: string | string[],
func: (txn: IDBTransaction) => T,
log: PrefixedLogger = logger,
log: Logger = logger,
): Promise<T> {
let startTime: number;
let description: string;
+2 -7
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger, PrefixedLogger } from "../../logger";
import { logger, Logger } from "../../logger";
import { LocalStorageCryptoStore } from "./localStorage-crypto-store";
import { MemoryCryptoStore } from "./memory-crypto-store";
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend";
@@ -697,12 +697,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
* reject with that exception. On synchronous backends, the
* exception will propagate to the caller of the getFoo method.
*/
public doTxn<T>(
mode: Mode,
stores: Iterable<string>,
func: (txn: IDBTransaction) => T,
log?: PrefixedLogger,
): Promise<T> {
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: IDBTransaction) => T, log?: Logger): Promise<T> {
return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ limitations under the License.
import { crypto } from "../crypto";
import { VerificationBase as Base } from "./Base";
import { newKeyMismatchError, newUserCancelledError } from "./Error";
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
import { decodeBase64, encodeUnpaddedBase64 } from "../../base64";
import { logger } from "../../logger";
import { VerificationRequest } from "./request/VerificationRequest";
import { MatrixClient } from "../../client";
+53 -14
View File
@@ -25,7 +25,6 @@ import { ConnectionError, MatrixError } from "./errors";
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts, Body } from "./interface";
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
import { QueryDict } from "../utils";
import { logger } from "../logger";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
@@ -112,8 +111,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param body - The HTTP JSON body.
*
* @param opts - additional options. If a number is specified,
* this is treated as `opts.localTimeoutMs`.
* @param opts - additional options.
* When `opts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
* when an expired token is encountered. Used to only attempt token refresh once.
*
* @returns Promise which resolves to
* ```
@@ -127,15 +127,18 @@ export class FetchHttpApi<O extends IHttpOpts> {
* @returns Rejects with an error if a problem occurred.
* This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T>(
public async authedRequest<T>(
method: Method,
path: string,
queryParams?: QueryDict,
body?: Body,
opts: IRequestOpts = {},
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
): Promise<ResponseType<T, O>> {
if (!queryParams) queryParams = {};
// avoid mutating paramOpts so they can be used on retry
const opts = { ...paramOpts };
if (this.opts.accessToken) {
if (this.opts.useAuthorizationHeader) {
if (!opts.headers) {
@@ -152,19 +155,53 @@ export class FetchHttpApi<O extends IHttpOpts> {
}
}
const requestPromise = this.request<T>(method, path, queryParams, body, opts);
try {
const response = await this.request<T>(method, path, queryParams, body, opts);
return response;
} catch (error) {
const err = error as MatrixError;
requestPromise.catch((err: MatrixError) => {
if (err.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
const shouldRetry = await this.tryRefreshToken();
// if we got a new token retry the request
if (shouldRetry) {
return this.authedRequest(method, path, queryParams, body, {
...paramOpts,
doNotAttemptTokenRefresh: true,
});
}
}
// otherwise continue with error handling
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
} else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
}
});
// return the original promise, otherwise tests break due to it having to
// go around the event loop one more time to process the result of the request
return requestPromise;
throw err;
}
}
/**
* Attempt to refresh access tokens.
* On success, sets new access and refresh tokens in opts.
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
*/
private async tryRefreshToken(): Promise<boolean> {
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
return false;
}
try {
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
this.opts.accessToken = accessToken;
this.opts.refreshToken = refreshToken;
// successfully got new tokens
return true;
} catch (error) {
this.opts.logger?.warn("Failed to refresh token", error);
return false;
}
}
/**
@@ -225,7 +262,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
): Promise<ResponseType<T, O>> {
const urlForLogs = this.sanitizeUrlForLogs(url);
logger.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
@@ -279,9 +316,11 @@ export class FetchHttpApi<O extends IHttpOpts> {
priority: opts.priority,
});
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
this.opts.logger?.debug(
`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`,
);
} catch (e) {
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
this.opts.logger?.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
if ((<Error>e).name === "AbortError") {
throw e;
}
+29
View File
@@ -15,9 +15,26 @@ limitations under the License.
*/
import { MatrixError } from "./errors";
import { Logger } from "../logger";
export type Body = Record<string, any> | BodyInit;
/**
* @experimental
* Unencrypted access and (optional) refresh token
*/
export type AccessTokens = {
accessToken: string;
refreshToken?: string;
};
/**
* @experimental
* Function that performs token refresh using the given refreshToken.
* Returns a promise that resolves to the refreshed access and (optional) refresh tokens.
*
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
*/
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
export interface IHttpOpts {
fetchFn?: typeof global.fetch;
@@ -27,10 +44,22 @@ export interface IHttpOpts {
extraParams?: Record<string, string>;
accessToken?: string;
/**
* Used in conjunction with tokenRefreshFunction to attempt token refresh
*/
refreshToken?: string;
/**
* Function to attempt token refresh when a possibly expired token is encountered
* Optional, only called when a refreshToken is present
*/
tokenRefreshFunction?: TokenRefreshFunction;
useAuthorizationHeader?: boolean; // defaults to true
onlyData?: boolean;
localTimeoutMs?: number;
/** Optional logger instance. If provided, requests and responses will be logged. */
logger?: Logger;
}
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
+69 -14
View File
@@ -15,7 +15,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import log, { Logger } from "loglevel";
import loglevel from "loglevel";
/** Logger interface used within the js-sdk codebase */
export interface Logger {
/**
* Output trace message to the logger, with stack trace.
*
* @param msg - Data to log.
*/
trace(...msg: any[]): void;
/**
* Output debug message to the logger.
*
* @param msg - Data to log.
*/
debug(...msg: any[]): void;
/**
* Output info message to the logger.
*
* @param msg - Data to log.
*/
info(...msg: any[]): void;
/**
* Output warn message to the logger.
*
* @param msg - Data to log.
*/
warn(...msg: any[]): void;
/**
* Output error message to the logger.
*
* @param msg - Data to log.
*/
error(...msg: any[]): void;
/**
* Create a child logger.
*
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
* use this as a prefix; others use a different mechanism.
*/
getChild(namespace: string): Logger;
}
// This is to demonstrate, that you can use any namespace you want.
// Namespaces allow you to turn on/off the logging for specific parts of the
@@ -30,7 +76,7 @@ const DEFAULT_NAMESPACE = "matrix";
// to avoid the issue, we override the methodFactory of loglevel that binds to the
// console methods at initialization time by a factory that looks up the console methods
// when logging so we always get the current value of console methods.
log.methodFactory = function (methodName, logLevel, loggerName) {
loglevel.methodFactory = function (methodName, logLevel, loggerName) {
return function (this: PrefixedLogger, ...args): void {
/* eslint-disable @typescript-eslint/no-invalid-this */
if (this.prefix) {
@@ -54,33 +100,42 @@ log.methodFactory = function (methodName, logLevel, loggerName) {
};
/**
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
* Implementation of {@link Logger} based on `loglevel`.
*
* @deprecated this shouldn't be public; prefer {@link Logger}.
*/
export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
logger.setLevel(log.levels.DEBUG, false);
export interface PrefixedLogger extends Logger {
export interface PrefixedLogger extends loglevel.Logger, Logger {
/** @deprecated prefer {@link Logger.getChild} */
withPrefix: (prefix: string) => PrefixedLogger;
/** @deprecated internal property */
prefix: string;
}
function extendLogger(logger: Logger): void {
(<PrefixedLogger>logger).withPrefix = function (prefix: string): PrefixedLogger {
/** Internal utility function to turn a `loglevel.Logger` into a `PrefixedLogger` */
function extendLogger(logger: loglevel.Logger): void {
const prefixedLogger = <PrefixedLogger>logger;
prefixedLogger.getChild = prefixedLogger.withPrefix = function (prefix: string): PrefixedLogger {
const existingPrefix = this.prefix || "";
return getPrefixedLogger(existingPrefix + prefix);
};
}
extendLogger(logger);
function getPrefixedLogger(prefix: string): PrefixedLogger {
const prefixLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
const prefixLogger = loglevel.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
if (prefixLogger.prefix !== prefix) {
// Only do this setup work the first time through, as loggers are saved by name.
extendLogger(prefixLogger);
prefixLogger.prefix = prefix;
prefixLogger.setLevel(log.levels.DEBUG, false);
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
}
return prefixLogger;
}
/**
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
*/
export const logger = loglevel.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
logger.setLevel(loglevel.levels.DEBUG, false);
extendLogger(logger);
+2
View File
@@ -29,6 +29,7 @@ export * from "./http-api";
export * from "./autodiscovery";
export * from "./sync-accumulator";
export * from "./errors";
export * from "./base64";
export * from "./models/beacon";
export * from "./models/event";
export * from "./models/room";
@@ -42,6 +43,7 @@ export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./models/search-result";
export * from "./oidc";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
+4 -4
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, RoomMember } from "../matrix";
import { MatrixEvent } from "../matrix";
import { deepCompare } from "../utils";
import { Focus } from "./focus";
@@ -42,11 +42,11 @@ export class CallMembership {
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
if (!parentEvent.sender) throw new Error("Invalid parent event: sender is null");
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
}
public get member(): RoomMember {
return this.parentEvent.sender!;
public get sender(): string | undefined {
return this.parentEvent.getSender();
}
public get callId(): string {
+33 -14
View File
@@ -118,10 +118,12 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}
callMemberships.sort((a, b) => a.createdTs() - b.createdTs());
logger.debug(
"Call memberships, in order: ",
callMemberships.map((m) => [m.createdTs(), m.member.userId]),
);
if (callMemberships.length > 1) {
logger.debug(
`Call memberships in room ${room.roomId}, in order: `,
callMemberships.map((m) => [m.createdTs(), m.sender]),
);
}
return callMemberships;
}
@@ -154,8 +156,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/**
* Performs cleanup & removes timers for client shutdown
*/
public stop(): void {
this.leaveRoomSession();
public async stop(): Promise<void> {
await this.leaveRoomSession(1000);
if (this.expiryTimeout) {
clearTimeout(this.expiryTimeout);
this.expiryTimeout = undefined;
@@ -195,11 +197,14 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* and stops scheduled updates.
* This will not unsubscribe from updates: remember to call unsubscribe() separately if
* desired.
* The membership update required to leave the session will retry if it fails.
* Without network connection the promise will never resolve.
* A timeout can be provided so that there is a guarantee for the promise to resolve.
*/
public leaveRoomSession(): void {
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
if (!this.isJoined()) {
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
return;
return new Promise((resolve) => resolve(false));
}
logger.info(`Leaving call session in room ${this.room.roomId}`);
@@ -207,7 +212,20 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
this.activeFoci = undefined;
this.membershipId = undefined;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
this.triggerCallMembershipEventUpdate();
const timeoutPromise = new Promise((r) => {
if (timeout) {
// will never resolve if timeout is not set
setTimeout(r, timeout, "timeout");
}
});
return new Promise((resolve) => {
Promise.race([this.triggerCallMembershipEventUpdate(), timeoutPromise]).then((value) => {
// The timeoutPromise returns the string 'timeout' and the membership update void
// A success implies that the membership update was quicker then the timeout.
resolve(value != "timeout");
});
});
}
/**
@@ -411,7 +429,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
};
let resendDelay;
try {
await this.client.sendStateEvent(
this.room.roomId,
@@ -422,12 +439,14 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
logger.info(`Sent updated call member event.`);
// check periodically to see if we need to refresh our member event
if (this.isJoined()) resendDelay = MEMBER_EVENT_CHECK_PERIOD;
if (this.isJoined()) {
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
}
} catch (e) {
resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
const resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
logger.warn(`Failed to send call member event: retrying in ${resendDelay}`);
await new Promise((resolve) => setTimeout(resolve, resendDelay));
await this.triggerCallMembershipEventUpdate();
}
if (resendDelay) this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, resendDelay);
}
}
+15 -6
View File
@@ -414,9 +414,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* Gets the event as though it would appear unencrypted. If the event is already not
* encrypted, it is simply returned as-is.
* @returns The event in wire format.
* Gets the event as it would appear if it had been sent unencrypted.
*
* If the event is encrypted, we attempt to mock up an event as it would have looked had the sender not encrypted it.
* If the event is not encrypted, a copy of it is simply returned as-is.
*
* @returns A shallow copy of the event, in wire format, as it would have been had it not been encrypted.
*/
public getEffectiveEvent(): IEvent {
const content = Object.assign({}, this.getContent()); // clone for mutation
@@ -1593,15 +1596,21 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
}
/**
* Summarise the event as JSON. This is currently used by React SDK's view
* event source feature and Seshat's event indexing, so take care when
* adjusting the output here.
* Summarise the event as JSON.
*
* If encrypted, include both the decrypted and encrypted view of the event.
*
* This is named `toJSON` for use with `JSON.stringify` which checks objects
* for functions named `toJSON` and will call them to customise the output
* if they are defined.
*
* **WARNING** Do not log the result of this method; otherwise, it will end up
* in rageshakes, leading to a privacy violation.
*
* @deprecated Prefer to use {@link MatrixEvent#getEffectiveEvent} or similar.
* This method will be removed soon; it is too easy to use it accidentally
* and cause a privacy violation (cf https://github.com/vector-im/element-web/issues/26380).
* In any case, the value it returns is not a faithful serialization of the object.
*/
public toJSON(): object {
const event = this.getEffectiveEvent();
+82 -6
View File
@@ -90,7 +90,40 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
private reEmitter: TypedReEmitter<ThreadEmittedEvents, ThreadEventHandlerMap>;
/**
* The last event in this thread, if we don't yet have this in the timeline.
*
* When we run {@link processRootEvent} (which I think happens during the
* setting-up of the thread), we set this to the event pointed to by the
* server in `latest_event` [1] that came through with the thread root.
*
* [1]: https://spec.matrix.org/v1.8/client-server-api/#server-side-aggregation-of-mthread-relationships
*
* Later, when we have populated the timeline, this is set to undefined, so
* that methods like {@link replyToEvent} fall through to use lastReply,
* which looks in the timeline for the latest event that is a "thread reply"
* i.e. directly refers to the thread root with an m.thread relation.
*
* So it looks like this is only really relevant when initialEventsFetched
* is false, because as soon as the initial events have been fetched, we
* should have a timeline (I think).
*
* If all replies in this thread are redacted, this is set to the root
* event. I'm not clear what the meaning of this is, since usually after the
* initial events have been fetched, lastEvent should be undefined.
* In fact, the whole usage inside onRedaction looks suspect - it may be
* that we were thinking lastEvent always refers to the actual last event,
* but it only does so before initialEventsFetched becomes true.
*
* The usage of lastEvent inside {@link onEcho} looks suspicious, since I'd
* think we probably mean {@link replyToEvent} there - we are trying not to
* echo a duplicate event, and we probably want that behaviour even after
* initialEventsFetched has become true.
*
* -- andyb
*/
private lastEvent: MatrixEvent | undefined;
private replyCount = 0;
private lastPendingEvent: MatrixEvent | undefined;
private pendingReplyCount = 0;
@@ -324,25 +357,34 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* @param emit - whether to emit the Update event if the thread was updated or not.
*/
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
// Modify this event to point at our room's state, and mark its thread
// as this.
this.setEventMetadata(event);
// Decide whether this event is going to be added at the end of the timeline.
const lastReply = this.lastReply();
const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
// Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) {
// all the relevant membership info to hydrate events with a sender
// is held in the main room timeline
// We want to fetch the room state from there and pass it down to this thread
// timeline set to let it reconcile an event with its relevant RoomMember
// When there's no server-side support, just add it to the end of the timeline.
this.addEventToTimeline(event, toStartOfTimeline);
this.client.decryptEventIfNeeded(event, {});
} else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
// When we've asked for the event to be added to the end, and we're
// not in the initial state, and this event belongs at the end, add it.
this.addEventToTimeline(event, false);
this.fetchEditsWhereNeeded(event);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
// If this event is not a direct member of the thread, but is a
// reference to something that is, then we have two cases:
if (!this.initialEventsFetched) {
// Case 1: we haven't yet fetched events from the server. In
// this case, when we do, the events we get back might only be
// the first-order ones, so this event (which is second-order -
// a reference to something directly in the thread) needs to be
// kept so we can replay it when the first-order ones turn up.
/**
* A thread can be fully discovered via a single sync response
* And when that's the case we still ask the server to do an initialisation
@@ -354,6 +396,23 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
*/
this.replayEvents?.push(event);
} else {
// Case 2: this is happening later, and we have a timeline. In
// this case, these events might be out-of order.
//
// Specifically, if the server doesn't support recursion, so we
// only get these events through sync, they might be coming
// later than the first-order ones, so we insert them based on
// timestamp (despite the problems with this documented in
// #3325).
//
// If the server does support recursion, we should have got all
// the interspersed events from the server when we fetched the
// initial events, so if they are coming via sync they should be
// the latest ones, so we can add them as normal.
//
// (Note that both insertEventIntoTimeline and addEventToTimeline
// do nothing if we have seen this event before.)
const recursionSupport =
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
@@ -367,6 +426,23 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
return;
} else if (this.initialEventsFetched) {
// If initial events have not been fetched, we are OK to throw away
// this event, because we are about to fetch all the events for this
// thread from the server.
// Otherwise, we should add it, but we suspect it is out of order.
if (toStartOfTimeline) {
// If we're adding at the start of the timeline, it doesn't
// matter that it's out of order.
this.addEventToTimeline(event, toStartOfTimeline);
} else {
// We think this event might be out of order, because isNewestReply
// is false (otherwise we would have gone into the earlier if
// clause), so try to insert it in the right place based on
// timestamp.
this.insertEventIntoTimeline(event);
}
}
if (
+21
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../matrix";
import { MatrixEvent } from "./event";
import { TypedEventEmitter } from "./typed-event-emitter";
@@ -152,6 +153,7 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
/**
* Construct a new User. A User must have an ID and can optionally have extra information associated with it.
* @param userId - Required. The ID of this user.
* @deprecated use `User.createUser`
*/
public constructor(public readonly userId: string) {
super();
@@ -160,6 +162,25 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
this.updateModifiedTime();
}
/**
* Construct a new User whose events will also emit on MatrixClient.
* A User must have an ID and can optionally have extra information associated with it.
* @param userId - Required. The ID of this user.
* @param client - An instance of MatrixClient object
* @returns User object with reEmitter setup on client
*/
public static createUser(userId: string, client: MatrixClient): User {
const user = new User(userId);
client.reEmitter.reEmit(user, [
UserEvent.AvatarUrl,
UserEvent.DisplayName,
UserEvent.Presence,
UserEvent.CurrentlyActive,
UserEvent.LastPresenceTs,
]);
return user;
}
/**
* Update this User with the given presence event. May fire "User.presence",
* "User.avatarUrl" and/or "User.displayName" if this event updates this user's
+18 -7
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
@@ -51,9 +51,9 @@ export type AuthorizationParams = {
* Generate the scope used in authorization request with OIDC OP
* @returns scope
*/
const generateScope = (): string => {
const deviceId = randomString(10);
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
export const generateScope = (deviceId?: string): string => {
const safeDeviceId = deviceId ?? randomString(10);
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${safeDeviceId}`;
};
// https://www.rfc-editor.org/rfc/rfc7636
@@ -122,8 +122,13 @@ export const generateAuthorizationUrl = async (
* @experimental
* Generate a URL to attempt authorization with the OP
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
* @param oidcClientSettings - oidc configuration
* @param homeserverName - used as state
* @param metadata - validated metadata from OP discovery
* @param clientId - this client's id as registered with the OP
* @param homeserverUrl - used to establish the session on return from the OP
* @param identityServerUrl - used to establish the session on return from the OP
* @param nonce - state
* @param prompt - indicates to the OP which flow the user should see - eg login or registration
* See https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-prompt-parameter
* @returns a Promise with the url as a string
*/
export const generateOidcAuthorizationUrl = async ({
@@ -133,6 +138,7 @@ export const generateOidcAuthorizationUrl = async ({
homeserverUrl,
identityServerUrl,
nonce,
prompt,
}: {
clientId: string;
metadata: ValidatedIssuerMetadata;
@@ -140,6 +146,7 @@ export const generateOidcAuthorizationUrl = async ({
identityServerUrl?: string;
redirectUri: string;
nonce: string;
prompt?: string;
}): Promise<string> => {
const scope = await generateScope();
const oidcClient = new OidcClient({
@@ -156,6 +163,7 @@ export const generateOidcAuthorizationUrl = async ({
const request = await oidcClient.createSigninRequest({
state: userState,
nonce,
prompt,
});
return request.url;
@@ -190,7 +198,8 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
* @param code - authorization code as returned by OP during authorization
* @param storedAuthorizationParams - stored params from start of oidc login flow
* @returns valid bearer token response
* @throws when request fails, or returned token response is invalid
* @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the request fails, or the returned token response is invalid.
*/
export const completeAuthorizationCodeGrant = async (
code: string,
@@ -199,6 +208,7 @@ export const completeAuthorizationCodeGrant = async (
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
tokenResponse: BearerTokenResponse;
homeserverUrl: string;
idTokenClaims: IdTokenClaims;
identityServerUrl?: string;
}> => {
/**
@@ -250,6 +260,7 @@ export const completeAuthorizationCodeGrant = async (
tokenResponse: normalizedTokenResponse,
homeserverUrl: userState.homeserverUrl,
identityServerUrl: userState.identityServerUrl,
idTokenClaims: signinResponse.profile,
};
} catch (error) {
logger.error("Oidc login failed", error);
+4
View File
@@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Errors expected to be encountered during OIDC discovery, client registration, and authentication.
* Not intended to be displayed directly to the user.
*/
export enum OidcError {
NotSupported = "OIDC authentication not supported",
Misconfigured = "OIDC is misconfigured",
+22
View File
@@ -0,0 +1,22 @@
/*
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.
*/
export * from "./authorize";
export * from "./discovery";
export * from "./error";
export * from "./register";
export * from "./tokenRefresher";
export * from "./validate";
+2 -1
View File
@@ -34,7 +34,8 @@ export type OidcRegistrationClientMetadata = {
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
* @param clientMetadata - registration metadata
* @returns resolves to the registered client id when registration is successful
* @throws when registration request fails, or response is invalid
* @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the registration request fails, or the response is invalid.
*/
const doRegistration = async (
registrationEndpoint: string,
+150
View File
@@ -0,0 +1,150 @@
/*
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 { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { AccessTokens } from "../http-api";
import { IDelegatedAuthConfig } from "../client";
import { generateScope } from "./authorize";
import { discoverAndValidateAuthenticationConfig } from "./discovery";
import { logger } from "../logger";
/**
* @experimental
* Class responsible for refreshing OIDC access tokens
*
* Client implementations will likely want to override {@link persistTokens} to persist tokens after successful refresh
*
*/
export class OidcTokenRefresher {
/**
* Promise which will complete once the OidcClient has been initialised
* and is ready to start refreshing tokens.
*
* Will reject if the client initialisation fails.
*/
public readonly oidcClientReady!: Promise<void>;
private oidcClient!: OidcClient;
private inflightRefreshRequest?: Promise<AccessTokens>;
public constructor(
/**
* Delegated auth config as found in matrix client .well-known
*/
authConfig: IDelegatedAuthConfig,
/**
* id of this client as registered with the OP
*/
clientId: string,
/**
* redirectUri as registered with OP
*/
redirectUri: string,
/**
* Device ID of current session
*/
deviceId: string,
/**
* idTokenClaims as returned from authorization grant
* used to validate tokens
*/
private readonly idTokenClaims: IdTokenClaims,
) {
this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri);
}
private async initialiseOidcClient(
authConfig: IDelegatedAuthConfig,
clientId: string,
deviceId: string,
redirectUri: string,
): Promise<void> {
try {
const config = await discoverAndValidateAuthenticationConfig(authConfig);
const scope = generateScope(deviceId);
this.oidcClient = new OidcClient({
...config.metadata,
client_id: clientId,
scope,
redirect_uri: redirectUri,
authority: config.metadata.issuer,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
});
} catch (error) {
logger.error("Failed to initialise OIDC client.", error);
throw new Error("Failed to initialise OIDC client.");
}
}
/**
* Attempt token refresh using given refresh token
* @param refreshToken - refresh token to use in request with token issuer
* @returns tokens - Promise that resolves with new access and refresh tokens
* @throws when token refresh fails
*/
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
if (!this.inflightRefreshRequest) {
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
}
try {
const tokens = await this.inflightRefreshRequest;
return tokens;
} finally {
this.inflightRefreshRequest = undefined;
}
}
/**
* Persist the new tokens, called after tokens are successfully refreshed.
*
* This function is intended to be overriden by the consumer when persistence is necessary.
*
* @param accessToken - new access token
* @param refreshToken - OPTIONAL new refresh token
*/
public async persistTokens(_tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
// NOOP
}
private async getNewTokens(refreshToken: string): Promise<AccessTokens> {
if (!this.oidcClient) {
throw new Error("Cannot get new token before OIDC client is initialised.");
}
const refreshTokenState = {
refresh_token: refreshToken,
session_state: "test",
data: undefined,
profile: this.idTokenClaims,
};
const response = await this.oidcClient.useRefreshToken({
state: refreshTokenState,
timeoutInSeconds: 300,
});
const tokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
};
await this.persistTokens(tokens);
return tokens;
}
}
@@ -25,7 +25,7 @@ import {
RendezvousTransport,
RendezvousFailureReason,
} from "..";
import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib";
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
import { generateDecimalSas } from "../../crypto/verification/SASDecimal";
import { UnstableValue } from "../../NamespacedValue";
+38 -14
View File
@@ -31,6 +31,7 @@ import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
import { UIAResponse } from "../@types/uia";
import { ToDeviceMessageId } from "../@types/event";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
@@ -60,7 +61,10 @@ export class OutgoingRequestProcessor {
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
public async makeOutgoingRequest<T>(
msg: OutgoingRequest | SigningKeysUploadRequest,
uiaCallback?: UIAuthCallback<T>,
): Promise<void> {
let resp: string;
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
@@ -82,23 +86,22 @@ export class OutgoingRequestProcessor {
msg.body,
);
} else if (msg instanceof ToDeviceRequest) {
const path =
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
encodeURIComponent(msg.txn_id);
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
resp = await this.sendToDeviceRequest(msg);
} else if (msg instanceof RoomMessageRequest) {
const path =
`/_matrix/client/v3/rooms/${encodeURIComponent(msg.room_id)}/send/` +
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else if (msg instanceof SigningKeysUploadRequest) {
resp = await this.makeRequestWithUIA(
await this.makeRequestWithUIA(
Method.Post,
"/_matrix/client/v3/keys/device_signing/upload",
{},
msg.body,
uiaCallback,
);
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
return;
} else {
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
@@ -122,6 +125,34 @@ export class OutgoingRequestProcessor {
}
}
/**
* Send the HTTP request for a `ToDeviceRequest`
*
* @param request - request to send
* @returns JSON-serialized body of the response, if successful
*/
private async sendToDeviceRequest(request: ToDeviceRequest): Promise<string> {
// a bit of extra logging, to help trace to-device messages through the system
const parsedBody: { messages: Record<string, Record<string, Record<string, any>>> } = JSON.parse(request.body);
const messageList = [];
for (const [userId, perUserMessages] of Object.entries(parsedBody.messages)) {
for (const [deviceId, message] of Object.entries(perUserMessages)) {
messageList.push(`${userId}/${deviceId} (msgid ${message[ToDeviceMessageId]})`);
}
}
logger.info(
`Sending batch of to-device messages. type=${request.event_type} txnid=${request.txn_id}`,
messageList,
);
const path =
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(request.event_type)}/` +
encodeURIComponent(request.txn_id);
return await this.rawJsonRequest(Method.Put, path, {}, request.body);
}
private async makeRequestWithUIA<T>(
method: Method,
path: string,
@@ -164,13 +195,6 @@ export class OutgoingRequestProcessor {
prefix: "",
};
try {
const response = await this.http.authedRequest<string>(method, path, queryParams, body, opts);
logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
return response;
} catch (e) {
logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
throw e;
}
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
}
}
+62 -13
View File
@@ -14,15 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
EncryptionAlgorithm,
EncryptionSettings,
OlmMachine,
RoomId,
UserId,
HistoryVisibility as RustHistoryVisibility,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { EventType } from "../@types/event";
import { IContent, MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { logger, PrefixedLogger } from "../logger";
import { Logger, logger } from "../logger";
import { KeyClaimManager } from "./KeyClaimManager";
import { RoomMember } from "../models/room-member";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { HistoryVisibility } from "../@types/partials";
/**
* RoomEncryptor: responsible for encrypting messages to a given room
@@ -30,11 +39,12 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
* @internal
*/
export class RoomEncryptor {
private readonly prefixedLogger: PrefixedLogger;
private readonly prefixedLogger: Logger;
/**
* @param olmMachine - The rust-sdk's OlmMachine
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
* @param outgoingRequestProcessor - The OutgoingRequestProcessor, which sends outgoing requests
* @param room - The room we want to encrypt for
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
*/
@@ -45,7 +55,7 @@ export class RoomEncryptor {
private readonly room: Room,
private encryptionSettings: IContent,
) {
this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`);
this.prefixedLogger = logger.getChild(`[${room.roomId} encryption]`);
}
/**
@@ -65,15 +75,14 @@ export class RoomEncryptor {
* @param member - new membership state
*/
public onRoomMembership(member: RoomMember): void {
this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`);
if (
member.membership == "join" ||
(member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
) {
// make sure we are tracking the deviceList for this user
this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`);
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]);
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]).catch((e) => {
this.prefixedLogger.error("Unable to update tracked users", e);
});
}
// TODO: handle leaves (including our own)
@@ -84,8 +93,10 @@ export class RoomEncryptor {
*
* This ensures that we have a megolm session ready to use and that we have shared its key with all the devices
* in the room.
*
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
*/
public async ensureEncryptionSession(): Promise<void> {
public async ensureEncryptionSession(globalBlacklistUnverifiedDevices: boolean): Promise<void> {
if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") {
throw new Error(
`Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`,
@@ -104,9 +115,28 @@ export class RoomEncryptor {
this.prefixedLogger.debug("Sessions for users are ready; now sharing room key");
const rustEncryptionSettings = new EncryptionSettings();
/* FIXME historyVisibility, rotation, etc */
rustEncryptionSettings.historyVisibility = toRustHistoryVisibility(this.room.getHistoryVisibility());
const shareMessages = await this.olmMachine.shareRoomKey(
// We only support megolm
rustEncryptionSettings.algorithm = EncryptionAlgorithm.MegolmV1AesSha2;
// We need to convert the rotation period from milliseconds to microseconds
// See https://spec.matrix.org/v1.8/client-server-api/#mroomencryption and
// https://matrix-org.github.io/matrix-rust-sdk-crypto-wasm/classes/EncryptionSettings.html#rotationPeriod
if (typeof this.encryptionSettings.rotation_period_ms === "number") {
rustEncryptionSettings.rotationPeriod = BigInt(this.encryptionSettings.rotation_period_ms * 1000);
}
if (typeof this.encryptionSettings.rotation_period_msgs === "number") {
rustEncryptionSettings.rotationPeriodMessages = BigInt(this.encryptionSettings.rotation_period_msgs);
}
// When this.room.getBlacklistUnverifiedDevices() === null, the global settings should be used
// See Room#getBlacklistUnverifiedDevices
rustEncryptionSettings.onlyAllowTrustedDevices =
this.room.getBlacklistUnverifiedDevices() ?? globalBlacklistUnverifiedDevices;
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
new RoomId(this.room.roomId),
userList,
rustEncryptionSettings,
@@ -135,9 +165,10 @@ export class RoomEncryptor {
* then encrypt the event using the session.
*
* @param event - Event to be encrypted.
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
*/
public async encryptEvent(event: MatrixEvent): Promise<void> {
await this.ensureEncryptionSession();
public async encryptEvent(event: MatrixEvent, globalBlacklistUnverifiedDevices: boolean): Promise<void> {
await this.ensureEncryptionSession(globalBlacklistUnverifiedDevices);
const encryptedContent = await this.olmMachine.encryptRoomEvent(
new RoomId(this.room.roomId),
@@ -153,3 +184,21 @@ export class RoomEncryptor {
);
}
}
/**
* Convert a HistoryVisibility to a RustHistoryVisibility
* @param visibility - HistoryVisibility enum
* @returns a RustHistoryVisibility enum
*/
export function toRustHistoryVisibility(visibility: HistoryVisibility): RustHistoryVisibility {
switch (visibility) {
case HistoryVisibility.Invited:
return RustHistoryVisibility.Invited;
case HistoryVisibility.Joined:
return RustHistoryVisibility.Joined;
case HistoryVisibility.Shared:
return RustHistoryVisibility.Shared;
case HistoryVisibility.WorldReadable:
return RustHistoryVisibility.WorldReadable;
}
}
+69 -7
View File
@@ -29,7 +29,7 @@ import { logger } from "../logger";
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
import { CryptoEvent, IMegolmSessionData } from "../crypto";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { encodeUri } from "../utils";
import { encodeUri, immediate } from "../utils";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { sleep } from "../utils";
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
@@ -82,7 +82,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
* Get the backup version we are currently backing up to, if any
*/
public async getActiveBackupVersion(): Promise<string | null> {
if (!this.olmMachine.isBackupEnabled()) return null;
if (!(await this.olmMachine.isBackupEnabled())) return null;
return this.activeBackupVersion;
}
@@ -95,11 +95,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info);
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
const pubKeyForSavedPrivateKey = backupKeys?.decryptionKey?.megolmV1PublicKey;
const decryptionKey = backupKeys?.decryptionKey;
const backupMatchesSavedPrivateKey =
info.algorithm === pubKeyForSavedPrivateKey?.algorithm &&
(info.auth_data as Curve25519AuthData)?.public_key === pubKeyForSavedPrivateKey.publicKeyBase64;
!!decryptionKey && backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
return {
matchesDecryptionKey: backupMatchesSavedPrivateKey,
trusted: signatureVerification.trusted(),
@@ -124,6 +122,48 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
}
return this.keyBackupCheckInProgress;
}
/**
* Handles a backup secret received event and store it if it matches the current backup version.
*
* @param secret - The secret as received from a `m.secert.send` event for secret `m.megolm_backup.v1`.
* @returns true if the secret is valid and has been stored, false otherwise.
*/
public async handleBackupSecretReceived(secret: string): Promise<boolean> {
// Currently we only receive the decryption key without any key backup version. It is important to
// check that the secret is valid for the current version before storing it.
// We force a check to ensure to have the latest version. We also want to check that the backup is trusted
// as we don't want to store the secret if the backup is not trusted, and eventually import megolm keys later from an untrusted backup.
const backupCheck = await this.checkKeyBackupAndEnable(true);
if (!backupCheck?.backupInfo?.version || !backupCheck.trustInfo.trusted) {
// There is no server-side key backup, or the backup is not signed by a trusted cross-signing key or trusted own device.
// This decryption key is useless to us.
logger.warn("Received backup decryption key, but there is no trusted server-side key backup");
return false;
}
try {
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(backupCheck.backupInfo, backupDecryptionKey);
if (!privateKeyMatches) {
logger.debug(`onReceiveSecret: backup decryption key does not match current backup version`);
// just ignore the secret
return false;
}
logger.info(
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
);
await this.olmMachine.saveBackupDecryptionKey(backupDecryptionKey, backupCheck.backupInfo.version);
return true;
} catch (e) {
logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
}
return false;
}
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
/** Helper for `checkKeyBackup` */
@@ -379,6 +419,25 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
}
}
/**
* Checks if the provided backup info matches the given private key.
*
* @param info - The backup info to check.
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
*/
function backupInfoMatchesBackupDecryptionKey(
info: KeyBackupInfo,
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
): boolean {
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
return false;
}
return (info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64;
}
/**
* Implementation of {@link BackupDecryptor} for the rust crypto backend.
*/
@@ -401,7 +460,7 @@ export class RustBackupDecryptor implements BackupDecryptor {
for (const [sessionId, sessionData] of Object.entries(ciphertexts)) {
try {
const decrypted = JSON.parse(
await this.decryptionKey.decryptV1(
this.decryptionKey.decryptV1(
sessionData.session_data.ephemeral,
sessionData.session_data.mac,
sessionData.session_data.ciphertext,
@@ -409,6 +468,9 @@ export class RustBackupDecryptor implements BackupDecryptor {
);
decrypted.session_id = sessionId;
keys.push(decrypted);
// there might be lots of sessions, so don't hog the event loop
await immediate();
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
+16 -3
View File
@@ -17,14 +17,15 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { RustCrypto } from "./rust-crypto";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { ServerSideSecretStorage } from "../secret-storage";
import { ICryptoCallbacks } from "../crypto";
import { Logger } from "../logger";
/**
* Create a new `RustCrypto` implementation
*
* @param logger - A `Logger` instance that will be used for debug output.
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
* We expect it to set the access token, etc.
* @param userId - The local user's User ID.
@@ -40,6 +41,7 @@ import { ICryptoCallbacks } from "../crypto";
* @internal
*/
export async function initRustCrypto(
logger: Logger,
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
@@ -52,7 +54,7 @@ export async function initRustCrypto(
await RustSdkCryptoJs.initAsync();
// enable tracing in the rust-sdk
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Trace).turnOn();
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
const u = new RustSdkCryptoJs.UserId(userId);
const d = new RustSdkCryptoJs.DeviceId(deviceId);
@@ -65,7 +67,7 @@ export async function initRustCrypto(
storePrefix ?? undefined,
(storePrefix && storePassphrase) ?? undefined,
);
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
rustCrypto.onRoomKeysUpdated(sessions),
);
@@ -73,6 +75,17 @@ export async function initRustCrypto(
rustCrypto.onUserIdentityUpdated(userId),
);
// Check if there are any key backup secrets pending processing. There may be multiple secrets to process if several devices have gossiped them.
// The `registerReceiveSecretCallback` function will only be triggered for new secrets. If the client is restarted before processing them, the secrets will need to be manually handled.
rustCrypto.checkSecrets("m.megolm_backup.v1");
// Register a callback to be notified when a new secret is received, as for now only the key backup secret is supported (the cross signing secrets are handled automatically by the OlmMachine)
await olmMachine.registerReceiveSecretCallback((name: string, _value: string) =>
// Instead of directly checking the secret value, we poll the inbox to get all values for that secret type.
// Once we have all the values, we can safely clear the secret inbox.
rustCrypto.checkSecrets(name),
);
// Tell the OlmMachine to think about its outgoing requests before we hand control back to the application.
//
// This is primarily a fudge to get it to correctly populate the `users_for_key_query` list, so that future
+208 -58
View File
@@ -24,12 +24,12 @@ import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { Room } from "../models/room";
import { RoomMember } from "../models/room-member";
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { Logger } from "../logger";
import { ClientPrefix, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { RoomEncryptor } from "./RoomEncryptor";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { KeyClaimManager } from "./KeyClaimManager";
import { MapWithDefault, encodeUri } from "../utils";
import { encodeUri, MapWithDefault } from "../utils";
import {
BackupTrustInfo,
BootstrapCrossSigningOpts,
@@ -62,7 +62,7 @@ import { keyFromPassphrase } from "../crypto/key_passphrase";
import { encodeRecoveryKey } from "../crypto/recoverykey";
import { crypto } from "../crypto/crypto";
import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification";
import { EventType } from "../@types/event";
import { EventType, MsgType } from "../@types/event";
import { CryptoEvent } from "../crypto";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupDecryptor, RustBackupManager } from "./backup";
@@ -70,7 +70,7 @@ import { TypedReEmitter } from "../ReEmitter";
import { randomString } from "../randomstring";
import { ClientStoppedError } from "../errors";
import { ISignatures } from "../@types/signed";
import { encodeBase64 } from "../common-crypto/base64";
import { encodeBase64 } from "../base64";
import { DecryptionError } from "../crypto/algorithms";
const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
@@ -88,7 +88,6 @@ const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
* @internal
*/
export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEventMap> implements CryptoBackend {
public globalErrorOnUnknownDevices = false;
private _trustCrossSignedDevices = true;
/** whether {@link stop} has been called */
@@ -118,6 +117,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
public constructor(
private readonly logger: Logger,
/** The `OlmMachine` from the underlying rust crypto sdk. */
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
@@ -143,7 +144,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
super();
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
this.eventDecryptor = new EventDecryptor(olmMachine, this);
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this);
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
this.reemitter.reEmit(this.backupManager, [
@@ -153,46 +154,78 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
]);
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);
// Check and start in background the key backup connection
this.checkKeyBackupAndEnable();
}
/**
* Attempts to retrieve a session from a key backup, if enough time
* Starts an attempt to retrieve a session from a key backup, if enough time
* has elapsed since the last check for this session id.
*
* If a backup is found, it is decrypted and imported.
*
* @param targetRoomId - ID of the room that the session is used in.
* @param targetSessionId - ID of the session for which to check backup.
*/
public async queryKeyBackupRateLimited(targetRoomId: string, targetSessionId: string): Promise<void> {
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
if (!backupKeys.decryptionKey) return;
const version = backupKeys.backupVersion;
public startQueryKeyBackupRateLimited(targetRoomId: string, targetSessionId: string): void {
const now = new Date().getTime();
if (
!this.sessionLastCheckAttemptedTime[targetSessionId!] ||
now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
) {
const lastCheck = this.sessionLastCheckAttemptedTime[targetSessionId];
if (!lastCheck || now - lastCheck > KEY_BACKUP_CHECK_RATE_LIMIT) {
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: targetRoomId,
$sessionId: targetSessionId,
this.queryKeyBackup(targetRoomId, targetSessionId).catch((e) => {
this.logger.error(`Unhandled error while checking key backup for session ${targetSessionId}`, e);
});
} else {
const lastCheckStr = new Date(lastCheck).toISOString();
this.logger.debug(
`Not checking key backup for session ${targetSessionId} (last checked at ${lastCheckStr})`,
);
}
}
const res = await this.http.authedRequest<KeyBackupSession>(Method.Get, path, { version }, undefined, {
/**
* Helper for {@link RustCrypto#startQueryKeyBackupRateLimited}.
*
* Requests the backup and imports it. Doesn't do any rate-limiting.
*
* @param targetRoomId - ID of the room that the session is used in.
* @param targetSessionId - ID of the session for which to check backup.
*/
private async queryKeyBackup(targetRoomId: string, targetSessionId: string): Promise<void> {
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
if (!backupKeys.decryptionKey) {
this.logger.debug(`Not checking key backup for session ${targetSessionId} (no decryption key)`);
return;
}
this.logger.debug(`Checking key backup for session ${targetSessionId}`);
const version = backupKeys.backupVersion;
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: targetRoomId,
$sessionId: targetSessionId,
});
let res: KeyBackupSession;
try {
res = await this.http.authedRequest<KeyBackupSession>(Method.Get, path, { version }, undefined, {
prefix: ClientPrefix.V3,
});
if (this.stopped) return;
const backupDecryptor = new RustBackupDecryptor(backupKeys.decryptionKey);
if (res) {
const sessionsToImport: Record<string, KeyBackupSession> = {};
sessionsToImport[targetSessionId] = res;
const keys = await backupDecryptor.decryptSessions(sessionsToImport);
for (const k of keys) {
k.room_id = targetRoomId!;
}
await this.importRoomKeys(keys);
}
} catch (e) {
this.logger.info(`No luck requesting key backup for session ${targetSessionId}: ${e}`);
return;
}
if (this.stopped) return;
const backupDecryptor = new RustBackupDecryptor(backupKeys.decryptionKey);
const sessionsToImport: Record<string, KeyBackupSession> = { [targetSessionId]: res };
const keys = await backupDecryptor.decryptSessions(sessionsToImport);
for (const k of keys) {
k.room_id = targetRoomId;
}
await this.importRoomKeys(keys);
}
/**
@@ -215,6 +248,15 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public set globalErrorOnUnknownDevices(_v: boolean) {
// Not implemented for rust crypto.
}
public get globalErrorOnUnknownDevices(): boolean {
// Not implemented for rust crypto.
return false;
}
public stop(): void {
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
// will cause an error.
@@ -240,7 +282,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`);
}
await encryptor.encryptEvent(event);
await encryptor.encryptEvent(event, this.globalBlacklistUnverifiedDevices);
}
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
@@ -262,8 +304,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* @param event - event to inspect
*/
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
// TODO: make this work properly. Or better, replace it.
const ret: Partial<IEncryptedEventInfo> = {};
ret.senderKey = event.getSenderKey() ?? undefined;
@@ -324,11 +364,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
public globalBlacklistUnverifiedDevices = false;
/**
* Implementation of {@link CryptoApi#getVersion}.
*/
public getVersion(): string {
const versions = RustSdkCryptoJs.getVersions();
return `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`;
}
public prepareToEncrypt(room: Room): void {
const encryptor = this.roomEncryptors[room.roomId];
if (encryptor) {
encryptor.ensureEncryptionSession();
encryptor.ensureEncryptionSession(this.globalBlacklistUnverifiedDevices);
}
}
@@ -1005,12 +1053,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* Implementation of {@link CryptoApi#storeSessionBackupPrivateKey}.
*
* @param key - the backup decryption key
* @param version - the backup version for this key.
*/
public async storeSessionBackupPrivateKey(key: Uint8Array): Promise<void> {
public async storeSessionBackupPrivateKey(key: Uint8Array, version?: string): Promise<void> {
const base64Key = encodeBase64(key);
// TODO get version from backupManager
await this.olmMachine.saveBackupDecryptionKey(RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64Key), "");
if (!version) {
throw new Error("storeSessionBackupPrivateKey: version is required");
}
await this.olmMachine.saveBackupDecryptionKey(
RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64Key),
version,
);
}
/**
@@ -1223,10 +1278,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
// start tracking devices for any users already known to be in this room.
const members = await room.getEncryptionTargetMembers();
logger.debug(
`[${room.roomId} encryption] starting to track devices for: `,
members.map((u) => `${u.userId} (${u.membership})`),
);
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
}
@@ -1306,11 +1357,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
private onRoomKeyUpdated(key: RustSdkCryptoJs.RoomKeyInfo): void {
if (this.stopped) return;
logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`);
this.logger.debug(
`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`,
);
const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key);
if (pendingList.length === 0) return;
logger.debug(
this.logger.debug(
"Retrying decryption on events:",
pendingList.map((e) => `${e.getId()}`),
);
@@ -1323,7 +1376,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
// and deduplicates repeated attempts for the same event.
for (const ev of pendingList) {
ev.attemptDecryption(this, { isRetry: true }).catch((_e) => {
logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
this.logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
});
}
}
@@ -1339,6 +1392,57 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
public async onUserIdentityUpdated(userId: RustSdkCryptoJs.UserId): Promise<void> {
const newVerification = await this.getUserVerificationStatus(userId.toString());
this.emit(CryptoEvent.UserTrustStatusChanged, userId.toString(), newVerification);
// If our own user identity has changed, we may now trust the key backup where we did not before.
// So, re-check the key backup status and enable it if available.
if (userId.toString() === this.userId) {
await this.checkKeyBackupAndEnable();
}
}
/**
* Handles secret received from the rust secret inbox.
*
* The gossipped secrets are received using the `m.secret.send` event type
* and are guaranteed to have been received over a 1-to-1 Olm
* Session from a verified device.
*
* The only secret currently handled in this way is `m.megolm_backup.v1`.
*
* @param name - the secret name
* @param value - the secret value
*/
private async handleSecretReceived(name: string, value: string): Promise<boolean> {
this.logger.debug(`onReceiveSecret: Received secret ${name}`);
if (name === "m.megolm_backup.v1") {
return await this.backupManager.handleBackupSecretReceived(value);
// XXX at this point we should probably try to download the backup and import the keys,
// or at least retry for the current decryption failures?
// Maybe add some signaling when a new secret is received, and let clients handle it?
// as it's where the restore from backup APIs are exposed.
}
return false;
}
/**
* Called when a new secret is received in the rust secret inbox.
*
* Will poll the secret inbox and handle the secrets received.
*
* @param name - The name of the secret received.
*/
public async checkSecrets(name: string): Promise<void> {
const pendingValues: string[] = await this.olmMachine.getSecretsFromInbox(name);
for (const value of pendingValues) {
if (await this.handleSecretReceived(name, value)) {
// If we have a valid secret for that name there is no point of processing the other secrets values.
// It's probably the same secret shared by another device.
break;
}
}
// Important to call this after handling the secrets as good hygiene.
await this.olmMachine.deleteSecretsFromInbox(name);
}
/**
@@ -1393,7 +1497,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
throw new Error("missing roomId in the event");
}
logger.debug(`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`);
this.logger.debug(
`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`,
);
await this.olmMachine.receiveVerificationEvent(
JSON.stringify({
@@ -1407,6 +1513,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
new RustSdkCryptoJs.RoomId(roomId),
);
if (
event.getType() === EventType.RoomMessage &&
event.getContent().msgtype === MsgType.KeyVerificationRequest
) {
const request: RustSdkCryptoJs.VerificationRequest | undefined = this.olmMachine.getVerificationRequest(
new RustSdkCryptoJs.UserId(event.getSender()!),
event.getId()!,
);
if (!request) {
// There are multiple reasons this can happen; probably the most likely is that the event is too old.
this.logger.info(
`Ignoring just-received verification request ${event.getId()} which did not start a rust-side verification`,
);
} else {
this.emit(
CryptoEvent.VerificationRequestReceived,
new RustVerificationRequest(
request,
this.outgoingRequestProcessor,
this._supportedVerificationMethods,
),
);
}
}
// that may have caused us to queue up outgoing requests, so make sure we send them.
this.outgoingRequestLoop();
}
@@ -1432,7 +1564,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
}
// fire off the loop in the background
this.outgoingRequestLoopInner().catch((e) => {
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
this.logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
});
}
@@ -1448,7 +1580,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
// if `this.outgoingRequestLoop()` was called while `OlmMachine.outgoingRequests()` was running.
this.outgoingRequestLoopOneMoreLoop = false;
logger.debug("Calling OlmMachine.outgoingRequests()");
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
if (this.stopped) {
@@ -1483,10 +1614,17 @@ class EventDecryptor {
() => new MapWithDefault<string, Set<MatrixEvent>>(() => new Set()),
);
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine, private readonly crypto: RustCrypto) {}
public constructor(
private readonly logger: Logger,
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
private readonly crypto: RustCrypto,
) {}
public async attemptEventDecryption(event: MatrixEvent): Promise<IEventDecryptionResult> {
logger.info("Attempting decryption of event", event);
this.logger.info(
`Attempting decryption of event ${event.getId()} in ${event.getRoomId()} from ${event.getSender()}`,
);
// add the event to the pending list *before* attempting to decrypt.
// then, if the key turns up while decryption is in progress (and
// decryption fails), we will schedule a retry.
@@ -1524,7 +1662,10 @@ class EventDecryptor {
session: content.sender_key + "|" + content.session_id,
},
);
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
this.crypto.startQueryKeyBackupRateLimited(
event.getRoomId()!,
event.getWireContent().session_id!,
);
break;
}
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
@@ -1535,7 +1676,10 @@ class EventDecryptor {
session: content.sender_key + "|" + content.session_id,
},
);
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
this.crypto.startQueryKeyBackupRateLimited(
event.getRoomId()!,
event.getWireContent().session_id!,
);
break;
}
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
@@ -1554,17 +1698,22 @@ class EventDecryptor {
}
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
if (!event.getClearContent()) {
if (!event.getClearContent() || event.isDecryptionFailure()) {
// not successfully decrypted
return null;
}
// special-case outgoing events, which the rust crypto-sdk will barf on
if (event.status !== null) {
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
}
const encryptionInfo = await this.olmMachine.getRoomEventEncryptionInfo(
stringifyEvent(event),
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
);
return rustEncryptionInfoToJsEncryptionInfo(encryptionInfo);
return rustEncryptionInfoToJsEncryptionInfo(this.logger, encryptionInfo);
}
/**
@@ -1636,6 +1785,7 @@ function stringifyEvent(event: MatrixEvent): string {
}
function rustEncryptionInfoToJsEncryptionInfo(
logger: Logger,
encryptionInfo: RustSdkCryptoJs.EncryptionInfo | undefined,
): EventEncryptionInfo | null {
if (encryptionInfo === undefined) {
@@ -1659,7 +1809,7 @@ function rustEncryptionInfoToJsEncryptionInfo(
}
let shieldReason: EventShieldReason | null;
if (shieldState.message === null) {
if (shieldState.message === undefined) {
shieldReason = null;
} else if (shieldState.message === "Encrypted by an unverified user.") {
// this case isn't actually used with lax shield semantics.
+9 -2
View File
@@ -392,7 +392,10 @@ export class RustVerificationRequest
* Implementation of {@link Crypto.VerificationRequest#generateQRCode}.
*/
public async generateQRCode(): Promise<Buffer | undefined> {
const innerVerifier: RustSdkCryptoJs.Qr = await this.inner.generateQrCode();
const innerVerifier: RustSdkCryptoJs.Qr | undefined = await this.inner.generateQrCode();
// If we are unable to generate a QRCode, we return undefined
if (!innerVerifier) return;
return Buffer.from(innerVerifier.toBytes());
}
@@ -566,7 +569,11 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
return VerificationPhase.Started;
case QrState.Confirmed:
// we have confirmed the other side's scan and sent an `m.key.verification.done`.
return VerificationPhase.Done;
//
// However, the verification is not yet "Done", because we have to wait until we have received the
// `m.key.verification.done` from the other side (in particular, we don't mark the device/identity as
// verified until that happens). If we return "Done" too soon, we risk the user cancelling the flow.
return VerificationPhase.Started;
case QrState.Reciprocated:
// although the rust SDK doesn't immediately send the `m.key.verification.start` on transition into this
// state, `RustVerificationRequest.scanQrCode` immediately calls `reciprocate()` and does so, so in practice
+8
View File
@@ -32,6 +32,8 @@ export interface ISavedSync {
accountData: IMinimalEvent[];
}
export type UserCreator = (userId: string) => User;
/**
* A store for most of the data js-sdk needs to store, apart from crypto data
*/
@@ -62,6 +64,12 @@ export interface IStore {
*/
storeRoom(room: Room): void;
/**
* Set the user creator which is used for creating User objects
* @param creator - A callback that accepts an user-id and returns an User object
*/
setUserCreator(creator: UserCreator): void;
/**
* Retrieve a room by its' room ID.
* @param roomId - The room ID.
-1
View File
@@ -191,7 +191,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
this.db?.close(); // this does not call onclose
this.disconnected = true;
this.db = undefined;
onClose?.();
};
this.db.onclose = (): void => {
this.disconnected = true;
+4 -2
View File
@@ -19,7 +19,6 @@ limitations under the License.
import { MemoryStore, IOpts as IBaseOpts } from "./memory";
import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend";
import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { logger } from "../logger";
import { ISavedSync } from "./index";
@@ -140,7 +139,10 @@ export class IndexedDBStore extends MemoryStore {
.then((userPresenceEvents) => {
logger.log(`IndexedDBStore.startup: processing presence events`);
userPresenceEvents.forEach(([userId, rawEvent]) => {
const u = new User(userId);
if (!this.createUser) {
throw new Error("createUser is undefined, it should be set with setUserCreator()!");
}
const u = this.createUser(userId);
if (rawEvent) {
u.setPresenceEvent(new MatrixEvent(rawEvent));
}
+7 -2
View File
@@ -25,7 +25,7 @@ import { IEvent, MatrixEvent } from "../models/event";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { RoomMember } from "../models/room-member";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { ISavedSync, IStore, UserCreator } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
@@ -63,6 +63,7 @@ export class MemoryStore implements IStore {
private clientOptions?: IStoredClientOpts;
private pendingToDeviceBatches: IndexedToDeviceBatch[] = [];
private nextToDeviceBatchId = 0;
protected createUser?: UserCreator;
/**
* Construct a new in-memory data store for the Matrix Client.
@@ -108,6 +109,10 @@ export class MemoryStore implements IStore {
});
}
public setUserCreator(creator: UserCreator): void {
this.createUser = creator;
}
/**
* Called when a room member in a room being tracked by this store has been
* updated.
@@ -119,7 +124,7 @@ export class MemoryStore implements IStore {
return;
}
const user = this.users[member.userId] || new User(member.userId);
const user = this.users[member.userId] || this.createUser?.(member.userId);
if (member.name) {
user.setDisplayName(member.name);
if (member.events.member) {
+8 -1
View File
@@ -23,7 +23,7 @@ import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { ISavedSync, IStore, UserCreator } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
@@ -117,6 +117,13 @@ export class StubStore implements IStore {
return [];
}
/**
* No-op.
*/
public setUserCreator(creator: UserCreator): void {
return;
}
/**
* Store events for a room.
* @param room - The room to store events for.
+4 -16
View File
@@ -26,7 +26,7 @@ limitations under the License.
import { Optional } from "matrix-events-sdk";
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
import { User, UserEvent } from "./models/user";
import { User } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
import { deepCopy, defer, IDeferred, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils";
import { Filter } from "./filter";
@@ -431,7 +431,7 @@ export class SyncApi {
if (user) {
user.setPresenceEvent(presenceEvent);
} else {
user = createNewUser(client, presenceEvent.getContent().user_id);
user = User.createUser(presenceEvent.getContent().user_id, client);
user.setPresenceEvent(presenceEvent);
client.store.storeUser(user);
}
@@ -530,7 +530,7 @@ export class SyncApi {
if (user) {
user.setPresenceEvent(presenceEvent);
} else {
user = createNewUser(this.client, presenceEvent.getContent().user_id);
user = User.createUser(presenceEvent.getContent().user_id, this.client);
user.setPresenceEvent(presenceEvent);
this.client.store.storeUser(user);
}
@@ -1150,7 +1150,7 @@ export class SyncApi {
if (user) {
user.setPresenceEvent(presenceEvent);
} else {
user = createNewUser(client, presenceEvent.getSender()!);
user = User.createUser(presenceEvent.getSender()!, client);
user.setPresenceEvent(presenceEvent);
client.store.storeUser(user);
}
@@ -1893,18 +1893,6 @@ export class SyncApi {
};
}
function createNewUser(client: MatrixClient, userId: string): User {
const user = new User(userId);
client.reEmitter.reEmit(user, [
UserEvent.AvatarUrl,
UserEvent.DisplayName,
UserEvent.Presence,
UserEvent.CurrentlyActive,
UserEvent.LastPresenceTs,
]);
return user;
}
// /!\ This function is not intended for public use! It's only exported from
// here in order to share some common logic with sliding-sync-sdk.ts.
export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room {
+511 -350
View File
File diff suppressed because it is too large Load Diff