Compare commits

..

110 Commits

Author SHA1 Message Date
RiotRobot fb9e258468 v28.0.0 2023-08-29 10:53:33 +01:00
RiotRobot 974723ceef Prepare changelog for v28.0.0 2023-08-29 10:53:30 +01:00
RiotRobot 9b8dcf53ed v28.0.0-rc.1 2023-08-22 15:18:45 +01:00
RiotRobot 324af3ee67 Prepare changelog for v28.0.0-rc.1 2023-08-22 15:18:42 +01:00
RiotRobot ec6c0946d4 v27.3.0-rc.1 2023-08-22 15:13:46 +01:00
RiotRobot e5f480b032 Prepare changelog for v27.3.0-rc.1 2023-08-22 15:13:43 +01:00
Florian Duros 6bf4ed8672 ElementR: Add CryptoApi.requestVerificationDM (#3643)
* Add `CryptoApi.requestVerificationDM`

* Fix RoomMessageRequest url

* Review changes

* Merge fixes

* Add BOB test data

* `requestVerificationDM` test works against old crypto (encrypted verification request)

* Update test data
2023-08-21 14:48:32 +00:00
Valere c18d691ef5 RustCrypto | Implement keybackup loop (#3652)
* Implement `CryptoApi.checkKeyBackup`

* Deprecate `MatrixClient.enableKeyBackup`.

* fix integ test

* more tests

* Implement keybackup loop

* cleaning

* update matrix-sdk-crypto-wasm to 1.2.1

* fix lint

* avoid real timer stuff

* Simplify test

* post merge lint fix

* revert change on yarn.lock

* code review

* Generate test data for exported keys

* code review cleaning

* cleanup legacy backup loop

* Update spec/test-utils/test-data/generate-test-data.py

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

* Update spec/test-utils/test-data/generate-test-data.py

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

* update yarn.lock for new wasm bindings

---------

Co-authored-by: Richard van der Hoff <richard@matrix.org>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-08-17 14:10:57 +00:00
ElementRobot 97cf73bc52 Resetting package fields for development 2023-08-15 13:19:02 +01:00
ElementRobot aa25103665 Merge branch 'master' into develop 2023-08-15 13:18:59 +01:00
ElementRobot 858db67778 v27.2.0 2023-08-15 13:17:57 +01:00
ElementRobot e230abee45 Prepare changelog for v27.2.0 2023-08-15 13:17:55 +01:00
Travis Ralston 8c16d69f3c Set minimum supported Matrix 1.1 version (drop legacy r0 versions) (#3007)
Co-authored-by: Germain <germains@element.io>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-08-14 09:25:15 +01:00
Michael Telatynski 55b9116c99 Re-export localStorage-crypto-store (#3660) 2023-08-11 06:02:52 +00:00
Florian Duros 3a5d66057e ElementR: Process all verification events, not just requests (#3650)
* Process all verification event

* Add test for `isVerificationEvent`

* Review changes

* Remove null comparison and add doc to remote echo

* review changes
2023-08-09 14:14:58 +00:00
Richard van der Hoff 3f7af189e4 Implement CryptoApi.checkKeyBackupAndEnable (#3633)
* Implement `CryptoApi.checkKeyBackup`

* Deprecate `MatrixClient.enableKeyBackup`.

* fix integ test

* more tests

---------

Co-authored-by: valere <valeref@matrix.org>
2023-08-09 09:59:03 +00:00
renovate[bot] 16ddcb0ed0 Lock file maintenance (#3659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 15:32:57 +00:00
renovate[bot] 9e35b8dd0a Update all non-major dependencies (#3656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 15:09:57 +00:00
renovate[bot] bed787b749 Update tibdex/backport digest to 9565281 (#3658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 14:14:28 +00:00
renovate[bot] d260b8be56 Update dependency eslint-config-prettier to v9 (#3657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 14:10:12 +00:00
renovate[bot] 97991dad02 Update dependency @types/node to v18.17.3 (#3655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 14:09:52 +00:00
renovate[bot] b8c19c47ab Update babel monorepo to v7.22.10 (#3654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-08 14:07:45 +00:00
ElementRobot 1476ffbd15 v27.2.0-rc.1 2023-08-08 15:01:44 +01:00
ElementRobot 62f0a65472 Prepare changelog for v27.2.0-rc.1 2023-08-08 15:01:41 +01:00
Charly Nguyen 2ef7ae7661 Allow knocking rooms (#3647)
Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>
2023-08-03 08:16:18 +00:00
Michael Telatynski 61c0a49971 Mark more logs as debug to tidy the console (#3645) 2023-08-02 20:40:32 +00:00
Michael Telatynski 2172f28888 Fix wrong handling of encrypted rooms when loading them from sync accumulator (#3640)
* Revert "Ensure we don't overinflate the total notification count (#3634)"

This reverts commit fd0c4a7f56.

* Fix wrong handling of encrypted rooms when loading them from sync accumulator

* Tidy up code, removing sections which didn't make any difference

* Add test
2023-08-02 09:53:34 +00:00
Florian Duros 2e9b34e0c3 Throw error if missing userId in CryptoApi.findVerificationRequestDMInProgress (#3641) 2023-08-01 12:20:01 +00:00
ElementRobot 5a782b7377 Resetting package fields for development 2023-08-01 12:28:37 +01:00
ElementRobot 54bc807056 Merge branch 'master' into develop 2023-08-01 12:28:33 +01:00
ElementRobot 9e07710d80 v27.1.0 2023-08-01 12:27:34 +01:00
ElementRobot e9ed91d800 Prepare changelog for v27.1.0 2023-08-01 12:27:31 +01:00
Michael Telatynski 88ba4fad71 Skip processing thread roots and fetching threads list when support is disabled (#3642)
* Skip processing thread roots and fetching threads list when support is disabled

* Enable threads support in tests
2023-07-31 18:16:42 +00:00
Michael Telatynski 21b3471453 Bump pagination limit to account for threaded events (#3638) 2023-07-31 16:59:55 +00:00
Florian Duros 0ada9803ab ElementR: Add CryptoApi.findVerificationRequestDMInProgress (#3601)
* Add `CryptoApi.findVerificationRequestDMInProgress`

* Fix linting and missing parameters

* Move `ROOM_ID` into `test-data`

* Remove verification request from `EventDecryptor` pending list

* Fix duplicate timeline event processing

* Add extra documentation

* Try to fix sonar error

* Use `roomId`

* Fix typo

* Review changes

* Review changes

* Fix `initRustCrypto` jsdoc

* Listen to `ClientEvent.Event` instead of `RoomEvent.Timeline`

* Fix missing room id in `generate-test-data.py`

* Review changes

* Review changes

* Handle encrypted event

* Fix linting

* Comments and run timers

* Ignore 404

* Fix test
2023-07-31 15:00:15 +00:00
Michael Telatynski 1744f0e97b Revert "Ensure we don't overinflate the total notification count (#3634)" (#3639) 2023-07-31 11:57:11 +01:00
Michael Telatynski fd0c4a7f56 Ensure we don't overinflate the total notification count (#3634)
* Ensure we don't overinflate the total notification count

By correctly comparing push rules before & after decryption

* DRY the code

* Testsssss

* Update tests
2023-07-28 15:05:11 +00:00
Michael Telatynski 615f7f9e72 Export more into the public interface (#3614) 2023-07-28 11:54:17 +00:00
renovate[bot] 77259e81c9 Update all non-major dependencies (#3630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-28 10:55:22 +00:00
Richard van der Hoff 2193cd9d1c Implement CryptoApi.isKeyBackupTrusted (#3632)
* Implement `CryptoApi.isKeyBackupTrusted`

Fixes https://github.com/vector-im/crypto-internal/issues/110

* Bump matrix-sdk-crypto-wasm to v1.2.0

* Back out some changes

These are unneeded, and break backwards compat
2023-07-28 09:54:55 +00:00
Valere 6d28154dcd Add CryptoApi.getActiveSessionBackupVersion() (#3555)
* stub backupmanager

* Implement `CryptoApi.getActiveSessionBackupVersion`

* Revert unnecessary change

we can do this later, once we have better test coverage

* more test coverage

---------

Co-authored-by: Richard van der Hoff <richard@matrix.org>
2023-07-28 08:04:20 +00:00
Richard van der Hoff 83d447adfe Clean up megolm-backup integ test (#3631)
* Add `CryptoApi.setDeviceVerified`

I need a way to mark devices as trusted for the backup tests.

* More tests

* Simplify E2EKeyResponder.addDeviceKeys

The user and device IDs are in the test data, so no need to pass them in

* Clean up key backup integration test

Make it use the CryptoApi rather than legacy `MatrixClient.crypto`, and use a
pre-signed backup instead of requiring a "blindlySignAnything" method.

* run megolm-backup tests on both crypto stacks

* avoid internal backupManager
2023-07-27 15:23:02 +00:00
Richard van der Hoff 73c9f4e322 Add CryptoApi.setDeviceVerified (#3624)
I need a way to mark devices as trusted for the backup tests.
2023-07-27 13:16:10 +01:00
renovate[bot] e6fa4cdb3c Lock file maintenance (#3629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-27 09:45:53 +00:00
Michael Telatynski a04653a72c Upgrade matrix-widget-api (#3621)
* Fix threads ending up with chunks of their timelines missing

* delint

* Upgrade matrix-widget-api
2023-07-27 09:21:14 +00:00
renovate[bot] 5f9341f39c Update dependency eslint-plugin-unicorn to v48 (#3628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-27 08:37:46 +00:00
renovate[bot] 906946c419 Update all non-major dependencies (#3626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-27 08:37:23 +00:00
renovate[bot] 4397b9d640 Update dependency @types/node to v18.17.0 (#3627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-27 08:37:02 +00:00
ElementRobot 90da2cf439 v27.1.0-rc.1 2023-07-27 09:28:56 +01:00
ElementRobot 6edd45787b Prepare changelog for v27.1.0-rc.1 2023-07-27 09:28:52 +01:00
Florian Duros 84444ec11e ElementR: Add CryptoApi.getCrossSigningKeyId (#3619)
* Add `CryptoApi.getCrossSigningKeyId`

* Rename `CrossSigningPubKey` to `CrossSigningKeyInfo`

* Remove old eslint disable

* Review changes

* Review changes
2023-07-26 16:09:49 +00:00
Richard van der Hoff 0e95df5dba Element-R: implement {get,store}SessionBackupPrivateKey (#3622) 2023-07-26 12:00:43 +01:00
Valere 29b815b678 Replace deprecated TestClient with fetchMock (#3550)
* replace deprecated TestClient with fetchMock

* add stop() api to BackupManager for clean shutdown

* fix merge

* code review cleaning

* lint

* Address review comments

* Remove unused `TestClient.expectKeyBackupQuery`

* clean up imports

---------

Co-authored-by: Richard van der Hoff <richard@matrix.org>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-07-25 20:42:41 +00:00
Richard van der Hoff 0cf056958b Fix broken unit tests for FetchHttpApi.getUrl (#3620)
These tests have broken on Node.js 18.17.0.

This is due to Node.js adopting an updated version of the URL parser, in which
the internal `Symbol(query)` property is populated lazily.

We shouldn't be relying on the internal state of the URL object anyway. Let's
just compare the stringified copy.
2023-07-25 19:48:15 +00:00
Florian Duros 79d4113a6b ElementR: Stub CheckOwnCrossSigningTrust, import cross signing keys and verify local device in bootstrapCrossSigning (#3608) 2023-07-25 18:03:43 +01:00
Michael Telatynski 8a80886358 Fix threads ending up with chunks of their timelines missing (#3618)
* Fix threads ending up with chunks of their timelines missing

* delint
2023-07-25 15:28:52 +00:00
Michael Telatynski de7959de6c Ensure we do not clobber a newer RR with an older unthreaded one (#3617)
* Ensure we do not clobber a newer RR with an older unthreaded one

or vice versa

* Fix test
2023-07-24 16:35:56 +00:00
Michael Telatynski 533c21a515 Fix registration check your emails stage regression (#3616)
* Fix registration check your emails stage regression

* Simplify diff

* Add test
2023-07-24 14:08:17 +00:00
Michael Telatynski 6b018b6927 Fix how Room::eventShouldLiveIn handles replies to unknown parents (#3615)
* Add warning

* Fix how Room::eventShouldLiveIn handles replies to unknown parents
2023-07-24 07:37:28 +00:00
Michael Telatynski 38c3abb364 Update downstream-artifacts.yml (#3613) 2023-07-20 21:43:49 +00:00
Michael Telatynski a47f319665 Only send threaded read receipts if threads support is enabled (#3612)
* Only send threaded read receipts if threads support is enabled

* Tests
2023-07-20 15:44:52 +00:00
Florian Duros ecef9fd755 Fix CryptoApi#getVerificationRequestsToDeviceInProgress (#3611) 2023-07-20 09:46:55 +00:00
Richard van der Hoff 7dffd8ffd3 Make sure to drop references to user device lists (#3610)
Empirically, this seems to fix some problems with leaking references to
IndexedDB.
2023-07-20 08:47:30 +00:00
Michael Telatynski 66492e7ba8 Fix edge cases around non-thread relations to thread roots and read receipts (#3607)
* Ensure non-thread relations to a thread root are actually in both timelines

* Make thread in sendReceipt & sendReadReceipt explicit rather than guessing it

* Apply suggestions from code review

* Fix Room::eventShouldLiveIn to better match Synapse to diverging ideas of notifications

* Update read receipt sending behaviour to align with Synapse

* Fix tests

* Fix thread rel type
2023-07-19 11:21:50 +00:00
Michael Telatynski 43b2404865 Specify /preview_url requests as low priority (#3609)
* Specify /preview_url requests as low priority

* Update src/@types/global.d.ts

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

* Simplify interface

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-07-19 09:29:41 +00:00
Michael Telatynski fed9910fa1 Remove unused better-docs & docdash (#3605) 2023-07-19 07:44:11 +00:00
RiotRobot f77662406c Resetting package fields for development 2023-07-18 12:53:43 +01:00
RiotRobot 8cc0cf1a70 Merge branch 'master' into develop 2023-07-18 12:53:38 +01:00
RiotRobot dfa2429094 v27.0.0 2023-07-18 12:52:00 +01:00
RiotRobot 3e2460707c Prepare changelog for v27.0.0 2023-07-18 12:51:57 +01:00
Michael Telatynski 706c084fa7 Add tests for room-hierarchy (#3606)
* Add tests for room-hierarchy

* overwriteroutes
2023-07-18 10:37:17 +00:00
renovate[bot] eb7faa6c07 Lock file maintenance (#3604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-17 17:59:37 +00:00
RiotRobot d45a0b894a v27.0.0-rc.2 2023-07-14 16:10:12 +01:00
RiotRobot 102739e0fb Prepare changelog for v27.0.0-rc.2 2023-07-14 16:10:09 +01:00
Andy Balaam 0d7e4a0fa5 Merge pull request #3603 from matrix-org/backport-3600-to-staging
[Backport staging] Fix read receipt sending behaviour around thread roots
2023-07-14 16:03:41 +01:00
Michael Telatynski d4628e78d4 Fix read receipt sending behaviour around thread roots (#3600)
* Fix read receipt sending behaviour around thread roots

* Update src/client.ts

Co-authored-by: Eric Eastwood <erice@element.io>

---------

Co-authored-by: Eric Eastwood <erice@element.io>
(cherry picked from commit b05f933d83)
2023-07-14 15:01:58 +00:00
Richard van der Hoff 0b193f4665 matrix-sdk-crypto-js -> matrix-sdk-crypto-wasm (#3602)
We've renamed matrix-sdk-crypto-js and released a v1.0.
2023-07-13 17:11:57 +00:00
Eric Eastwood 8ef2e848b9 Log query parameters on HTTP requests (#3591)
* Log query parameters on HTTP requests

Follow-up to https://github.com/matrix-org/matrix-js-sdk/pull/3485

* Only stringify once

See https://github.com/matrix-org/matrix-js-sdk/pull/3591#discussion_r1261300323
2023-07-13 13:07:01 +00:00
Richard van der Hoff d92936fba5 Element-R: support for displaying QR codes during verification (#3588)
* Support for showing QR codes

* Emit `VerificationRequestEvent.Change` events when the verifier changes

* Minor integ test tweaks

* Handle transitions from QR code display to SAS

* Fix naming

* Add a test for `ShowQrCodeCallbacks.cancel`
2023-07-13 11:11:13 +00:00
Michael Telatynski f005984df3 Export typed event emitter key types (#3597)
* Export typed event emitter key types

* Update src/matrix.ts
2023-07-13 11:10:24 +00:00
Richard van der Hoff 13fec49e74 Element-R: ensure that userHasCrossSigningKeys uses up-to-date data (#3599)
* Element-R: ensure that `userHasCrossSigningKeys` uses up-to-date data

* Bump matrix-sdk-crypto-js
2023-07-13 10:46:56 +00:00
renovate[bot] 008294cfc6 Update babel monorepo to v7.22.9 (#3434)
* Update babel monorepo to v7.22.9

* Make babel happier

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-07-13 09:41:03 +00:00
Michael Telatynski b05f933d83 Fix read receipt sending behaviour around thread roots (#3600)
* Fix read receipt sending behaviour around thread roots

* Update src/client.ts

Co-authored-by: Eric Eastwood <erice@element.io>

---------

Co-authored-by: Eric Eastwood <erice@element.io>
2023-07-13 08:25:23 +00:00
Michael Telatynski b186d79dde Fix jest/valid-expects lints (#3586) 2023-07-12 17:11:52 +00:00
Michael Telatynski e82b5fe1db Fix types in getSessionBackupPrivateKey (#3595)
* Fix type issue around `getSessionBackupPrivateKey`

* Fix sending auth: null due to broken types around UIA

* Discard changes to src/crypto/index.ts

* Add comment

* Fix types

* Fix types for MatrixClient::addThreePid

* Iterate
2023-07-12 14:38:14 +00:00
Michael Telatynski 9602aa88ea Fix sending auth: null due to broken types around UIA (#3594)
* Fix type issue around `getSessionBackupPrivateKey`

* Fix sending auth: null due to broken types around UIA

* Discard changes to src/crypto/index.ts

* Add comment
2023-07-12 13:55:02 +00:00
renovate[bot] 0fb3dc1b13 Update typescript-eslint monorepo to v5.62.0 (#3583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-12 11:01:00 +00:00
renovate[bot] aeede332be Update dependency @types/jest to v29.5.3 (#3582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-12 10:59:05 +00:00
renovate[bot] b052950a19 Update JS-DevTools/npm-publish action to v2.2.1 (#3581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-12 10:57:45 +00:00
Michael Telatynski 1cb5fff5a1 Improve types (#3589)
* Improve types

* Improve coverage
2023-07-12 10:39:33 +00:00
Michael Telatynski 01226e41d9 Fix broken DeviceList.spec.ts test (#3590) 2023-07-12 10:01:57 +00:00
dependabot[bot] e3919fd93b Bump semver from 5.7.1 to 5.7.2 (#3575)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-07-12 02:20:47 +00:00
Kerry 3b88ea19b7 Stabilize support for MSC3952: intentional mentions (#3397)
* use stable identifiers for MSC3952: intentional mentions

* add matrix version to feature support for intentional mentions
2023-07-11 22:04:06 +00:00
Richard van der Hoff dcf26f3e48 bump rust-sdk (#3587) 2023-07-11 17:40:39 +00:00
Valere 3385adf5f6 Improve logging of http requests to aid debugging (#3485)
* Simple request logging with status and duration

* remove url params from logs

* superfluous toString()

* Add tests

* Apply suggestions from code review

* update snapshots

* update log format

* Apply suggestions from code review

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

* update snapshot

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2023-07-11 17:27:42 +00:00
Richard van der Hoff 9db6ce107a Add support for scanning QR codes during verification, with Rust crypto (#3565)
* Offer `m.qr_code.scan.v1` verification method by default

Normally, the application specifies the supported verification methods when
creating the MatrixClient (and matrix-react-sdk does so). If the application
leaves it unset, then the idea is that the js-sdk offers all known verification
methods.

However, by default, the rust-sdk doesn't specify `m.qr_code.scan.v1`. So
basically, we need to set our own list of supported methods, rather than
relying on the rust-sdk's defaults.

* Factor out base class from `RustSASVerifier`

* Implement QR code scanning

* Update src/rust-crypto/verification.ts
2023-07-11 16:00:59 +00:00
Florian Duros d5b22e1deb Use cryptoBackend in client.ts for new rust-crypto implementation (#3576)
* Use `cryptoBackend` in `client.ts` for new rust-crypto implementation for backward compatibility

* Use `cryptoBackend` in `client.ts` for new rust-crypto implementation for backward compatibility
2023-07-11 14:13:53 +00:00
Richard van der Hoff a5e606a1e7 Mark all the rust crypto stuff internal (#3574)
... for the avoidance of doubt.
2023-07-11 14:11:35 +00:00
Michael Telatynski f2471b6dbd Add methods to influence set_presence on /sync API calls (#3578)
* Add methods to influence set_presence on /sync API calls

* Tweak comment

* Improve coverage
2023-07-11 13:31:12 +00:00
RiotRobot dcf71e0c8f v27.0.0-rc.1 2023-07-11 13:37:19 +01:00
RiotRobot 77267e393c Prepare changelog for v27.0.0-rc.1 2023-07-11 13:37:16 +01:00
Michael Telatynski 1fdc0af5b7 Throw saner error when peeking has its room pulled out from under it (#3577) 2023-07-11 10:24:57 +00:00
Michael Telatynski d2b782a2f5 Simplify MatrixClient::setPowerLevel API (#3570)
* Simplify `MatrixClient::setPowerLevel` API

While making it more resilient to causing issues like nuking room state

* Handle edge case

* Fix tests

* Add test coverage
2023-07-11 07:26:30 +00:00
Kerry 5df4ebaada OIDC: Log in (#3554)
* use oidc-client-ts during oidc discovery

* export new type for auth config

* deprecate generateAuthorizationUrl in favour of generateOidcAuthorizationUrl

* testing util for oidc configurations

* test generateOidcAuthorizationUrl

* lint

* test discovery

* dont pass whole client wellknown to oidc validation funcs

* add nonce

* use oidc-client-ts for oidc response

* validate user state and update tests

* use oidc-client-ts for code exchange

* use oidc-client-ts in completing auth grant

* use client userState for homeserver

* more comments
2023-07-11 02:20:19 +00:00
renovate[bot] e68a1471c1 Update all non-major dependencies (#3564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-10 14:38:14 +00:00
Richard van der Hoff e42dd74426 Add async method for generating a QR code (#3562)
The api to generate a QR code is async in rust, and the easiest way to deal
with it is to make a new method.
2023-07-10 14:22:10 +01:00
renovate[bot] 2751e191d3 Lock file maintenance (#3392)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-10 10:08:16 +00:00
Michael Telatynski b5b86bf1b5 Fix TypedEventEmitter::removeAllListeners(void) not working (#3561) 2023-07-10 10:04:38 +00:00
dependabot[bot] 4990bf5ca0 Bump tough-cookie from 4.1.2 to 4.1.3 (#3560)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.1.2...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-10 08:24:08 +00:00
Kerry b8fa030d5d OIDC: use oidc-client-ts (#3544)
* use oidc-client-ts during oidc discovery

* export new type for auth config

* deprecate generateAuthorizationUrl in favour of generateOidcAuthorizationUrl

* testing util for oidc configurations

* test generateOidcAuthorizationUrl

* lint

* test discovery

* dont pass whole client wellknown to oidc validation funcs

* add nonce

* use client userState for homeserver
2023-07-09 21:19:32 +00:00
112 changed files with 7471 additions and 3709 deletions
-3
View File
@@ -66,9 +66,6 @@ module.exports = {
// Disabled tests are a reality for now but as soon as all of the xits are
// eliminated, we should enforce this.
"jest/no-disabled-tests": "off",
// TODO: There are many tests with invalid expects that should be fixed,
// https://github.com/matrix-org/matrix-js-sdk/issues/2976
"jest/valid-expect": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
)
)
steps:
- uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2
- uses: tibdex/backport@9565281eda0731b1d20c4025c43339fb0a23812e # v2
with:
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
+1 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
cypress:
name: Cypress
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.74.0
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.77.1
permissions:
actions: read
issues: read
+4 -2
View File
@@ -1,6 +1,8 @@
name: Build downstream artifacts
on:
pull_request: {}
# We only want the Rust Crypto Cypress tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume 4 concurrent runners.
# Anyone working on Rust Crypto is able to run the tests locally if required.
merge_group:
types: [checks_requested]
@@ -19,7 +21,7 @@ concurrency:
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.74.0
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.77.1
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@a25b4180b728b0279fca97d4e5bccf391685aead # v2.2.0
uses: JS-DevTools/npm-publish@5a85faf05d2ade2d5b6682bfe5359915d5159c6c # v2.2.1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
+92
View File
@@ -1,3 +1,95 @@
Changes in [28.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.0.0) (2023-08-29)
==================================================================================================
## 🚨 BREAKING CHANGES
* Set minimum supported Matrix 1.1 version (drop legacy r0 versions) ([\#3007](https://github.com/matrix-org/matrix-js-sdk/pull/3007)). Fixes vector-im/element-web#16876.
## 🦖 Deprecations
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
## ✨ Features
* ElementR: Add `CryptoApi.requestVerificationDM` ([\#3643](https://github.com/matrix-org/matrix-js-sdk/pull/3643)). Contributed by @florianduros.
* Implement `CryptoApi.checkKeyBackupAndEnable` ([\#3633](https://github.com/matrix-org/matrix-js-sdk/pull/3633)). Fixes vector-im/crypto-internal#111 and vector-im/crypto-internal#112.
## 🐛 Bug Fixes
* ElementR: Process all verification events, not just requests ([\#3650](https://github.com/matrix-org/matrix-js-sdk/pull/3650)). Contributed by @florianduros.
Changes in [27.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.2.0) (2023-08-15)
==================================================================================================
## 🦖 Deprecations
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
## ✨ Features
* Allow knocking rooms ([\#3647](https://github.com/matrix-org/matrix-js-sdk/pull/3647)). Contributed by @charlynguyen.
* Bump pagination limit to account for threaded events ([\#3638](https://github.com/matrix-org/matrix-js-sdk/pull/3638)).
* ElementR: Add `CryptoApi.findVerificationRequestDMInProgress` ([\#3601](https://github.com/matrix-org/matrix-js-sdk/pull/3601)). Contributed by @florianduros.
* Export more into the public interface ([\#3614](https://github.com/matrix-org/matrix-js-sdk/pull/3614)).
## 🐛 Bug Fixes
* Fix wrong handling of encrypted rooms when loading them from sync accumulator ([\#3640](https://github.com/matrix-org/matrix-js-sdk/pull/3640)). Fixes vector-im/element-web#25803.
* Skip processing thread roots and fetching threads list when support is disabled ([\#3642](https://github.com/matrix-org/matrix-js-sdk/pull/3642)).
* Ensure we don't overinflate the total notification count ([\#3634](https://github.com/matrix-org/matrix-js-sdk/pull/3634)). Fixes vector-im/element-web#25803.
Changes in [27.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.1.0) (2023-08-01)
==================================================================================================
## 🦖 Deprecations
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
## ✨ Features
* ElementR: Add `CryptoApi.getCrossSigningKeyId` ([\#3619](https://github.com/matrix-org/matrix-js-sdk/pull/3619)). Contributed by @florianduros.
* ElementR: Stub `CheckOwnCrossSigningTrust`, import cross signing keys and verify local device in `bootstrapCrossSigning` ([\#3608](https://github.com/matrix-org/matrix-js-sdk/pull/3608)). Contributed by @florianduros.
* Specify /preview_url requests as low priority ([\#3609](https://github.com/matrix-org/matrix-js-sdk/pull/3609)). Fixes vector-im/element-web#7292.
* Element-R: support for displaying QR codes during verification ([\#3588](https://github.com/matrix-org/matrix-js-sdk/pull/3588)). Fixes vector-im/crypto-internal#124.
* Add support for scanning QR codes during verification, with Rust crypto ([\#3565](https://github.com/matrix-org/matrix-js-sdk/pull/3565)).
* Add methods to influence set_presence on /sync API calls ([\#3578](https://github.com/matrix-org/matrix-js-sdk/pull/3578)).
## 🐛 Bug Fixes
* Fix threads ending up with chunks of their timelines missing ([\#3618](https://github.com/matrix-org/matrix-js-sdk/pull/3618)). Fixes vector-im/element-web#24466.
* Ensure we do not clobber a newer RR with an older unthreaded one ([\#3617](https://github.com/matrix-org/matrix-js-sdk/pull/3617)). Fixes vector-im/element-web#25806.
* Fix registration check your emails stage regression ([\#3616](https://github.com/matrix-org/matrix-js-sdk/pull/3616)).
* Fix how `Room::eventShouldLiveIn` handles replies to unknown parents ([\#3615](https://github.com/matrix-org/matrix-js-sdk/pull/3615)). Fixes vector-im/element-web#22603.
* Only send threaded read receipts if threads support is enabled ([\#3612](https://github.com/matrix-org/matrix-js-sdk/pull/3612)).
* ElementR: Fix `userId` parameter usage in `CryptoApi#getVerificationRequestsToDeviceInProgress` ([\#3611](https://github.com/matrix-org/matrix-js-sdk/pull/3611)). Contributed by @florianduros.
* Fix edge cases around non-thread relations to thread roots and read receipts ([\#3607](https://github.com/matrix-org/matrix-js-sdk/pull/3607)).
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
* Export typed event emitter key types ([\#3597](https://github.com/matrix-org/matrix-js-sdk/pull/3597)). Fixes #3506.
* Element-R: ensure that `userHasCrossSigningKeys` uses up-to-date data ([\#3599](https://github.com/matrix-org/matrix-js-sdk/pull/3599)). Fixes vector-im/element-web#25773.
* Fix sending `auth: null` due to broken types around UIA ([\#3594](https://github.com/matrix-org/matrix-js-sdk/pull/3594)).
Changes in [27.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.0.0) (2023-07-18)
==================================================================================================
## 🚨 BREAKING CHANGES
* Drop support for Node 16 ([\#3533](https://github.com/matrix-org/matrix-js-sdk/pull/3533)).
* Improve types around login, registration, UIA and identity servers ([\#3537](https://github.com/matrix-org/matrix-js-sdk/pull/3537)).
## 🦖 Deprecations
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
* Simplify `MatrixClient::setPowerLevel` API ([\#3570](https://github.com/matrix-org/matrix-js-sdk/pull/3570)). Fixes vector-im/element-web#13900 and #1844.
* Deprecate `VerificationRequest.getQRCodeBytes` and replace it with the asynchronous `generateQRCode`. ([\#3562](https://github.com/matrix-org/matrix-js-sdk/pull/3562)).
* Deprecate `VerificationRequest.beginKeyVerification()` in favour of `VerificationRequest.startVerification()`. ([\#3528](https://github.com/matrix-org/matrix-js-sdk/pull/3528)).
* Deprecate `Crypto.VerificationRequest` application event, replacing it with `Crypto.VerificationRequestReceived`. ([\#3514](https://github.com/matrix-org/matrix-js-sdk/pull/3514)).
## ✨ Features
* Throw saner error when peeking has its room pulled out from under it ([\#3577](https://github.com/matrix-org/matrix-js-sdk/pull/3577)). Fixes vector-im/element-web#18679.
* OIDC: Log in ([\#3554](https://github.com/matrix-org/matrix-js-sdk/pull/3554)). Contributed by @kerryarchibald.
* Prevent threads code from making identical simultaneous API hits ([\#3541](https://github.com/matrix-org/matrix-js-sdk/pull/3541)). Fixes vector-im/element-web#25395.
* Update IUnsigned type to be extensible ([\#3547](https://github.com/matrix-org/matrix-js-sdk/pull/3547)).
* add stop() api to BackupManager for clean shutdown ([\#3553](https://github.com/matrix-org/matrix-js-sdk/pull/3553)).
* Log the message ID of any undecryptable to-device messages ([\#3543](https://github.com/matrix-org/matrix-js-sdk/pull/3543)).
* Ignore thread relations on state events for consistency with edits ([\#3540](https://github.com/matrix-org/matrix-js-sdk/pull/3540)).
* OIDC: validate id token ([\#3531](https://github.com/matrix-org/matrix-js-sdk/pull/3531)). Contributed by @kerryarchibald.
## 🐛 Bug Fixes
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
* Fix `TypedEventEmitter::removeAllListeners(void)` not working ([\#3561](https://github.com/matrix-org/matrix-js-sdk/pull/3561)).
* Don't allow Olm unwedging rate-limiting to race ([\#3549](https://github.com/matrix-org/matrix-js-sdk/pull/3549)). Fixes vector-im/element-web#25716.
* Fix an instance of failed to decrypt error when an in flight `/keys/query` fails. ([\#3486](https://github.com/matrix-org/matrix-js-sdk/pull/3486)).
* Use the right anchor emoji for SAS verification ([\#3534](https://github.com/matrix-org/matrix-js-sdk/pull/3534)).
* fix a bug which caused the wrong emoji to be shown during SAS device verification. ([\#3523](https://github.com/matrix-org/matrix-js-sdk/pull/3523)).
Changes in [26.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0) (2023-07-04)
==================================================================================================
+2
View File
@@ -11,6 +11,8 @@
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
browser or in Node.js.
#### Minimum Matrix server version: v1.1
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
guarantees that a feature will be supported for at least 4 spec releases. For example, if a feature the js-sdk supports
is removed in v1.4 then the feature is _eligible_ for removal from the SDK when v1.8 is released. This SDK has no
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "26.2.0",
"version": "28.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -32,8 +32,8 @@
"keywords": [
"matrix-org"
],
"main": "./src/index.ts",
"browser": "./src/browser-index.ts",
"main": "./lib/index.js",
"browser": "./lib/browser-index.js",
"matrix_src_main": "./src/index.ts",
"matrix_src_browser": "./src/browser-index.ts",
"matrix_lib_main": "./lib/index.js",
@@ -55,14 +55,15 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.1",
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.1",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^3.1.2",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.3.1",
"matrix-widget-api": "^1.5.0",
"oidc-client-ts": "^2.2.4",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
@@ -96,22 +97,20 @@
"allchange": "^1.0.6",
"babel-jest": "^29.0.0",
"babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"browserify-swap": "^0.2.2",
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.43.0",
"eslint": "8.46.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^46.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^47.0.0",
"eslint-plugin-unicorn": "^48.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
@@ -157,5 +156,6 @@
"no-rust-crypto": {
"src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts"
}
}
},
"typings": "./lib/index.d.ts"
}
+1 -18
View File
@@ -32,8 +32,6 @@ import { syncPromise } from "./test-utils/test-utils";
import { createClient, IStartClientOpts } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
import { IKeyBackupSession } from "../src/crypto/keybackup";
import { IKeysUploadResponse, IUploadKeysRequest } from "../src/client";
import { ISyncResponder } from "./test-utils/SyncResponder";
@@ -92,7 +90,7 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
logger.log(this + ": starting");
this.httpBackend.when("GET", "/versions").respond(200, {
// we have tests that rely on support for lazy-loading members
versions: ["r0.5.0"],
versions: ["v1.1"],
});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
@@ -214,21 +212,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
});
}
/**
* Set up expectations that the client will query key backups for a particular session
*/
public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) {
this.httpBackend
.when(
"GET",
encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: roomId,
$sessionId: sessionId,
}),
)
.respond(status, response);
}
/**
* get the uploaded curve25519 device key
*
+186 -4
View File
@@ -18,9 +18,22 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { createClient, IAuthDict, MatrixClient } from "../../../src";
import { mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { AuthDict, createClient, CryptoEvent, MatrixClient } from "../../../src";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import { encryptAES } from "../../../src/crypto/aes";
import { CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import {
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
SIGNED_CROSS_SIGNING_KEYS_DATA,
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
} from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -39,8 +52,32 @@ const TEST_DEVICE_ID = "xzcvb";
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
// backend. Once we drop support for legacy crypto, it will go away.
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: ISyncResponder;
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
// Encryption key used to encrypt cross signing keys
const encryptionKey = new Uint8Array(32);
/**
* Create the {@link CryptoCallbacks}
*/
function createCryptoCallbacks(): CryptoCallbacks {
return {
getSecretStorageKey: (keys, name) => {
return Promise.resolve<[string, Uint8Array]>(["key_id", encryptionKey]);
},
};
}
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
@@ -52,8 +89,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
cryptoCallbacks: createCryptoCallbacks(),
});
syncResponder = new SyncResponder(homeserverUrl);
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
/** an object which intercepts `/keys/upload` requests on the test homeserver */
new E2EKeyReceiver(homeserverUrl);
await initCrypto(aliceClient);
});
@@ -68,7 +111,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
* @param authDict - The parameters to as the `auth` dict in the key upload request.
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
async function bootstrapCrossSigning(authDict: IAuthDict): Promise<void> {
async function bootstrapCrossSigning(authDict: AuthDict): Promise<void> {
await aliceClient.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
});
@@ -105,6 +148,94 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
);
});
newBackendOnly("get cross signing keys from secret storage and import them", async () => {
// Return public cross signing keys
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// Encrypt the private keys and return them in the /sync response as if they are in Secret Storage
const masterKey = await encryptAES(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
encryptionKey,
"m.cross_signing.master",
);
const selfSigningKey = await encryptAES(
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
encryptionKey,
"m.cross_signing.self_signing",
);
const userSigningKey = await encryptAES(
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
encryptionKey,
"m.cross_signing.user_signing",
);
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.cross_signing.master",
content: {
encrypted: {
key_id: masterKey,
},
},
},
{
type: "m.cross_signing.self_signing",
content: {
encrypted: {
key_id: selfSigningKey,
},
},
},
{
type: "m.cross_signing.user_signing",
content: {
encrypted: {
key_id: userSigningKey,
},
},
},
{
type: "m.secret_storage.key.key_id",
content: {
key: "key_id",
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
],
},
});
await aliceClient.startClient();
await syncPromise(aliceClient);
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
);
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
// Check if the UserTrustStatusChanged event was fired
expect(await userTrustStatusChangedPromise).toBe(aliceClient.getUserId());
// Expect the signature to be uploaded
expect(fetchMock.called("upload-sigs")).toBeTruthy();
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
const body = JSON.parse(sigsOpts!.body as string);
// the device should have a signature with the public self cross signing keys.
expect(body).toHaveProperty(
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
);
});
});
describe("getCrossSigningStatus()", () => {
@@ -157,4 +288,55 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
expect(isCrossSigningReady).toBeTruthy();
});
});
describe("getCrossSigningKeyId", () => {
/**
* Intercept /keys/device_signing/upload request and return the cross signing keys
* https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3keysdevice_signingupload
*
* @returns the cross signing keys
*/
function awaitCrossSigningKeysUpload() {
return new Promise<any>((resolve) => {
fetchMock.post(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
name: "upload-keys",
},
(url, options) => {
const content = JSON.parse(options.body as string);
resolve(content);
return {};
},
// Override the routes define in `mockSetupCrossSigningRequests`
{ overwriteRoutes: true },
);
});
}
it("should return the cross signing key id for each cross signing key", async () => {
mockSetupCrossSigningRequests();
// Intercept cross signing keys upload
const crossSigningKeysPromise = awaitCrossSigningKeysUpload();
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
// Get the cross signing keys
const crossSigningKeys = await crossSigningKeysPromise;
const getPubKey = (crossSigningKey: any) => Object.values(crossSigningKey!.keys)[0];
const masterKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId();
expect(masterKeyId).toBe(getPubKey(crossSigningKeys.master_key));
const selfSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.SelfSigning);
expect(selfSigningKeyId).toBe(getPubKey(crossSigningKeys.self_signing_key));
const userSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.UserSigning);
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
});
});
});
+221 -66
View File
@@ -20,22 +20,23 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { MockResponse, MockResponseFunction } from "fetch-mock";
import Olm from "@matrix-org/olm";
import type { IDeviceKeys } from "../../../src/@types/crypto";
import * as testUtils from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID, TEST_ROOM_ID as ROOM_ID, TEST_USER_ID } from "../../test-utils/test-data";
import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import {
Category,
createClient,
IClaimOTKsResult,
IContent,
IDownloadKeyResult,
IEvent,
IJoinedRoom,
IndexedDBCryptoStore,
IStartClientOpts,
ISyncResponse,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
@@ -43,6 +44,7 @@ import {
Room,
RoomMember,
RoomStateEvent,
IRoomEvent,
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
@@ -53,8 +55,7 @@ import { flushPromises } from "../../test-utils/flushPromises";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { CryptoCallbacks } from "../../../src/crypto-api";
const ROOM_ID = "!room:id";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -156,18 +157,23 @@ function encryptMegolmEvent(opts: {
expect(opts.room_id).toBeTruthy();
plaintext.room_id = opts.room_id;
}
return encryptMegolmEventRawPlainText({ senderKey: opts.senderKey, groupSession: opts.groupSession, plaintext });
return encryptMegolmEventRawPlainText({
senderKey: opts.senderKey,
groupSession: opts.groupSession,
plaintext,
});
}
function encryptMegolmEventRawPlainText(opts: {
senderKey: string;
groupSession: Olm.OutboundGroupSession;
plaintext: Partial<IEvent>;
origin_server_ts?: number;
}): IEvent {
return {
event_id: "$test_megolm_event_" + Math.random(),
sender: "@not_the_real_sender:example.com",
origin_server_ts: 1672944778000,
sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com",
origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000,
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)),
@@ -213,55 +219,6 @@ function encryptGroupSessionKey(opts: {
});
}
// get a /sync response which contains a single room (ROOM_ID), with the members given
function getSyncResponse(roomMembers: string[]): ISyncResponse {
const roomResponse: IJoinedRoom = {
summary: {
"m.heroes": [],
"m.joined_member_count": roomMembers.length,
"m.invited_member_count": roomMembers.length,
},
state: {
events: [
testUtils.mkEventCustom({
sender: roomMembers[0],
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
}),
],
},
timeline: {
events: [],
prev_batch: "",
},
ephemeral: { events: [] },
account_data: { events: [] },
unread_notifications: {},
};
for (let i = 0; i < roomMembers.length; i++) {
roomResponse.state.events.push(
testUtils.mkMembershipCustom({
membership: "join",
sender: roomMembers[i],
}),
);
}
return {
next_batch: "1",
rooms: {
join: { [ROOM_ID]: roomResponse },
invite: {},
leave: {},
},
account_data: { events: [] },
};
}
/**
* Establish an Olm Session with the test user
*
@@ -415,7 +372,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
let aliceClient: MatrixClient;
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
let keyReceiver: IE2EKeyReceiver;
let keyReceiver: E2EKeyReceiver;
/** an object which intercepts `/keys/query` requests on the test homeserver */
let keyResponder: E2EKeyResponder;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: ISyncResponder;
@@ -586,6 +546,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
afterEach(async () => {
await aliceClient.stopClient();
// Allow in-flight things to complete before we tear down the test
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
@@ -708,6 +672,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await syncPromise(aliceClient);
await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.isDecryptionFailure()).toBeFalsy();
expect(event.getContent().body).toEqual("42");
});
@@ -1980,7 +1945,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
};
};
for (const path of ["/_matrix/client/r0/keys/upload", "/_matrix/client/v3/keys/upload"]) {
for (const path of ["/_matrix/client/v3/keys/upload", "/_matrix/client/v3/keys/upload"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener, {
// These routes are already defined in the E2EKeyReceiver
// We want to overwrite the behaviour of the E2EKeyReceiver
@@ -2117,9 +2082,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
return queryResponseBody;
};
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
}
fetchMock.post(
new URL("/_matrix/client/v3/keys/query", aliceClient.getHomeserverUrl()).toString(),
listener,
);
});
}
@@ -2210,7 +2176,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)`
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)`
* Resolved when a key is uploaded (ie in `body.content.key`)
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
@@ -2219,7 +2185,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// This url is called multiple times during the secret storage bootstrap process
// When we received the newly generated key, we return it
fetchMock.put(
"express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)",
"express:/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)",
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
@@ -2235,7 +2201,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
}
/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
* Resolved when the cross signing key is uploaded
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
@@ -2243,7 +2209,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
return new Promise((resolve) => {
// Called when the cross signing key is uploaded
fetchMock.put(
`express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`,
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
resolve(content.encrypted);
@@ -2411,4 +2377,193 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(selfSigningKey[secretStorageKey]).toBeDefined();
});
});
describe("Incoming verification in a DM", () => {
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
keyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
keyResponder.addKeyReceiver(TEST_USER_ID, keyReceiver);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
});
afterEach(() => {
jest.useRealTimers();
});
/**
* Return a verification request event from Bob
* @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest
*/
function createVerificationRequestEvent(): IRoomEvent {
return {
content: {
body: "Verification request from Bob to Alice",
from_device: "BobDevice",
methods: ["m.sas.v1"],
msgtype: "m.key.verification.request",
to: aliceClient.getUserId()!,
},
event_id: "$143273582443PhrSn:example.org",
origin_server_ts: Date.now(),
room_id: TEST_ROOM_ID,
sender: "@bob:xyz",
type: "m.room.message",
unsigned: {
age: 1234,
},
};
}
/**
* Create a to-device event
* @param groupSession
* @param p2pSession
*/
function createToDeviceEvent(groupSession: Olm.OutboundGroupSession, p2pSession: Olm.Session): Partial<IEvent> {
return encryptGroupSessionKey({
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: keyReceiver.getDeviceKey(),
recipientEd25519Key: keyReceiver.getSigningKey(),
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
}
/**
* Create and encrypt a verification request event
* @param groupSession
*/
function createEncryptedMessage(groupSession: Olm.OutboundGroupSession): IEvent {
return encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
plaintext: createVerificationRequestEvent(),
});
}
newBackendOnly("Verification request from Bob to Alice", async () => {
// Tell alice she is sharing a room with bob
const syncResponse = getSyncResponse(["@bob:xyz"]);
// Add verification request from Bob to Alice in the DM between them
syncResponse.rooms[Category.Join][TEST_ROOM_ID].timeline.events.push(createVerificationRequestEvent());
syncResponder.sendOrQueueSyncResponse(syncResponse);
// Wait for the sync response to be processed
await syncPromise(aliceClient);
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// Expect to find the verification request received during the sync
expect(request?.roomId).toBe(TEST_ROOM_ID);
expect(request?.isSelfVerification).toBe(false);
expect(request?.otherUserId).toBe("@bob:xyz");
});
newBackendOnly("Verification request not found", async () => {
// Tell alice she is sharing a room with bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
// Wait for the sync response to be processed
await syncPromise(aliceClient);
// Expect to not find any verification request
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
expect(request).not.toBeDefined();
});
newBackendOnly("Process encrypted verification request", async () => {
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event, but don't send it yet
const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession);
// Add verification request from Bob to Alice in the DM between them
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } },
});
// Wait for the sync response to be processed
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const matrixEvent = room.getLiveTimeline().getEvents()[0];
// wait for a first attempt at decryption: should fail
await testUtils.awaitDecryption(matrixEvent);
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
// Send the Bob's keys
syncResponder.sendOrQueueSyncResponse({
next_batch: 2,
to_device: {
events: [toDeviceEvent],
},
});
await syncPromise(aliceClient);
// Wait for the message to be decrypted
await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// Expect to find the verification request received during the sync
expect(request?.roomId).toBe(TEST_ROOM_ID);
expect(request?.isSelfVerification).toBe(false);
expect(request?.otherUserId).toBe("@bob:xyz");
});
newBackendOnly(
"If Bob keys are not received in the 5mins after the verification request, the request is ignored",
async () => {
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event, but don't send it yet
const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession);
jest.useFakeTimers();
// Add verification request from Bob to Alice in the DM between them
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } },
});
// Wait for the sync response to be processed
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const matrixEvent = room.getLiveTimeline().getEvents()[0];
// wait for a first attempt at decryption: should fail
await testUtils.awaitDecryption(matrixEvent);
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
// Advance time by 5mins, the verification request should be ignored after that
jest.advanceTimersByTime(5 * 60 * 1000);
// Send the Bob's keys
syncResponder.sendOrQueueSyncResponse({
next_batch: 2,
to_device: {
events: [toDeviceEvent],
},
});
await syncPromise(aliceClient);
// Wait for the message to be decrypted
await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// the request should not be present
expect(request).not.toBeDefined();
},
);
});
});
+639 -84
View File
@@ -14,17 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Account } from "@matrix-org/olm";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { logger } from "../../../src/logger";
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
import { TestClient } from "../../TestClient";
import { IEvent } from "../../../src";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient, TypedEventEmitter } from "../../../src";
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 * as testData from "../../test-utils/test-data";
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import { IKeyBackup } from "../../../src/crypto/backup";
const ROOM_ID = "!ROOM:ID";
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
const TEST_HOMESERVER_URL = "https://alice-server.com";
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
const ENCRYPTED_EVENT: Partial<IEvent> = {
@@ -65,62 +74,122 @@ const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
},
};
const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";
const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
/**
* start an Olm session with a given recipient
*/
function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise<Olm.Session> {
return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => {
const otkId = Object.keys(keys)[0];
const otk = keys[otkId];
const session = new global.Olm.Session();
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
return session;
});
enum MockKeyUploadEvent {
KeyUploaded = "KeyUploaded",
}
describe("megolm key backups", function () {
if (!global.Olm) {
logger.warn("not running megolm tests: Olm not present");
return;
type MockKeyUploadEventHandlerMap = {
[MockKeyUploadEvent.KeyUploaded]: (roomId: string, sessionId: string, backupVersion: string) => void;
};
/*
* Test helper. Returns an event emitter that will emit an event every time fetchmock sees a request to backup a key.
*/
function mockUploadEmitter(
expectedVersion: string,
): TypedEventEmitter<MockKeyUploadEvent, MockKeyUploadEventHandlerMap> {
const emitter = new TypedEventEmitter();
fetchMock.put(
"path:/_matrix/client/v3/room_keys/keys",
(url, request) => {
const version = new URLSearchParams(new URL(url).search).get("version");
if (version != expectedVersion) {
return {
status: 403,
body: {
current_version: expectedVersion,
errcode: "M_WRONG_ROOM_KEYS_VERSION",
error: "Wrong backup version.",
},
};
}
const uploadPayload: IKeyBackup = JSON.parse(request.body?.toString() ?? "{}");
let count = 0;
for (const [roomId, value] of Object.entries(uploadPayload.rooms)) {
for (const sessionId of Object.keys(value.sessions)) {
emitter.emit(MockKeyUploadEvent.KeyUploaded, roomId, sessionId, version);
count++;
}
}
return {
status: 200,
body: {
count: count,
etag: "abcdefg",
},
};
},
{
overwriteRoutes: true,
},
);
return emitter;
}
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests on the test homeserver */
let syncResponder: SyncResponder;
/** an object which intercepts `/keys/upload` requests on the test homeserver */
let e2eKeyReceiver: E2EKeyReceiver;
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
jest.useFakeTimers();
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
mockInitialApiRequests(TEST_HOMESERVER_URL);
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
e2eKeyResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
});
afterEach(async () => {
if (aliceClient !== undefined) {
await aliceClient.stopClient();
}
// Allow in-flight things to complete before we tear down the test
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
const client = createClient({
baseUrl: TEST_HOMESERVER_URL,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
...opts,
});
await initCrypto(client);
return client;
}
const Olm = global.Olm;
let testOlmAccount: Olm.Account;
let aliceTestClient: TestClient;
const setupTestClient = (): [Account, TestClient] => {
const aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs");
const testOlmAccount = new Olm.Account();
testOlmAccount!.create();
return [testOlmAccount, aliceTestClient];
};
beforeAll(function () {
return Olm.init();
});
beforeEach(async function () {
[testOlmAccount, aliceTestClient] = setupTestClient();
await aliceTestClient!.client.initCrypto();
aliceTestClient!.client.crypto!.backupManager.backupInfo = CURVE25519_BACKUP_INFO;
});
afterEach(function () {
return aliceTestClient!.stop();
});
it("Alice checks key backups when receiving a message she can't decrypt", function () {
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
const syncResponse = {
next_batch: 1,
rooms: {
@@ -134,37 +203,523 @@ describe("megolm key backups", function () {
},
};
return aliceTestClient!
.start()
.then(() => {
return createOlmSession(testOlmAccount, aliceTestClient);
})
.then(() => {
const privkey = decodeRecoveryKey(RECOVERY_KEY);
return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey);
})
.then(() => {
aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse);
aliceTestClient!.expectKeyBackupQuery(ROOM_ID, SESSION_ID, 200, CURVE25519_KEY_BACKUP_DATA);
return aliceTestClient!.httpBackend.flushAllExpected();
})
.then(function (): Promise<MatrixEvent> {
const room = aliceTestClient!.client.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
if (event.getContent()) {
return Promise.resolve(event);
}
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
return new Promise((resolve, reject) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
// start after saving the private key
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.checkKeyBackup();
// 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);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent()).toEqual("testytest");
});
describe("backupLoop", () => {
it("Alice should upload known keys when backup is enabled", async function () {
// 404 means that there is no active backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// check that signalling is working
const remainingZeroPromise = new Promise<void>((resolve, reject) => {
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
if (remaining == 0) {
resolve();
}
});
});
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
const uploadMockEmitter = mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
const uploadPromises = someRoomKeys.map((data) => {
new Promise<void>((resolve) => {
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
if (
data.room_id == roomId &&
data.session_id == sessionId &&
version == testData.SIGNED_BACKUP_DATA.version
) {
resolve();
}
});
});
})
.then((event) => {
expect(event.getContent()).toEqual("testytest");
});
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
overwriteRoutes: true,
});
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
await aliceCrypto.importRoomKeys(someRoomKeys);
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
jest.runAllTimers();
await Promise.all(uploadPromises);
// Wait until all keys are backed up to ensure that when a new key is received the loop is restarted
await remainingZeroPromise;
// A new key import should trigger a new upload.
const newKey = testData.MEGOLM_SESSION_DATA;
const newKeyUploadPromise = new Promise<void>((resolve) => {
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
if (
newKey.room_id == roomId &&
newKey.session_id == sessionId &&
version == testData.SIGNED_BACKUP_DATA.version
) {
resolve();
}
});
});
await aliceCrypto.importRoomKeys([newKey]);
jest.runAllTimers();
await newKeyUploadPromise;
});
it("Alice should re-upload all keys if a new trusted backup is available", async function () {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// check that signalling is working
const remainingZeroPromise = new Promise<void>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
if (remaining == 0) {
resolve();
}
});
});
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
overwriteRoutes: true,
});
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
await aliceCrypto.importRoomKeys(someRoomKeys);
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
jest.runAllTimers();
// wait for all keys to be backed up
await remainingZeroPromise;
const newBackupVersion = "2";
const uploadMockEmitter = mockUploadEmitter(newBackupVersion);
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
newBackup.version = newBackupVersion;
// Let's simulate that a new backup is available by returning error code on key upload
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
overwriteRoutes: true,
});
// If we import a new key the loop will try to upload to old version, it will
// fail then check the current version and switch if trusted
const uploadPromises = someRoomKeys.map((data) => {
new Promise<void>((resolve) => {
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
if (data.room_id == roomId && data.session_id == sessionId && version == newBackupVersion) {
resolve();
}
});
});
});
const disableOldBackup = new Promise<void>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupFailed, (errCode) => {
if (errCode == "M_WRONG_ROOM_KEYS_VERSION") {
resolve();
}
});
});
const enableNewBackup = new Promise<void>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});
// A new key import should trigger a new upload.
const newKey = testData.MEGOLM_SESSION_DATA;
const newKeyUploadPromise = new Promise<void>((resolve) => {
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
if (newKey.room_id == roomId && newKey.session_id == sessionId && version == newBackupVersion) {
resolve();
}
});
});
await aliceCrypto.importRoomKeys([newKey]);
jest.runAllTimers();
await disableOldBackup;
await enableNewBackup;
jest.runAllTimers();
await Promise.all(uploadPromises);
await newKeyUploadPromise;
});
it("Backup loop should be resistant to network failures", async function () {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
overwriteRoutes: true,
});
// on the first key upload attempt, simulate a network failure
const failurePromise = new Promise((resolve) => {
fetchMock.put(
"path:/_matrix/client/v3/room_keys/keys",
() => {
resolve(undefined);
throw new TypeError(`Failed to fetch`);
},
{
overwriteRoutes: true,
},
);
});
// kick the import loop off and wait for the failed request
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
await aliceCrypto.importRoomKeys(someRoomKeys);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
jest.runAllTimers();
await failurePromise;
// Fix the endpoint to do successful uploads
const successPromise = new Promise((resolve) => {
fetchMock.put(
"path:/_matrix/client/v3/room_keys/keys",
() => {
resolve(undefined);
return {
status: 200,
body: {
count: 2,
etag: "abcdefg",
},
};
},
{
overwriteRoutes: true,
},
);
});
// check that a `KeyBackupSessionsRemaining` event is emitted with `remaining == 0`
const allKeysUploadedPromise = new Promise((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
if (remaining == 0) {
resolve(undefined);
}
});
});
// run the timers, which will make the backup loop redo the request
await jest.runAllTimersAsync();
await successPromise;
await allKeysUploadedPromise;
});
});
it("getActiveSessionBackupVersion() should give correct result", async function () {
// 404 means that there is no active backup
fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceCrypto.checkKeyBackupAndEnable();
// At this point there is no backup
let backupStatus: string | null;
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toBeNull();
// Serve a backup with no trusted signature
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
delete unsignedBackup.auth_data.signatures;
fetchMock.get("express:/_matrix/client/v3/room_keys/version", unsignedBackup, {
overwriteRoutes: true,
});
const checked = await aliceCrypto.checkKeyBackupAndEnable();
expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version);
expect(checked?.trustInfo?.trusted).toBeFalsy();
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toBeNull();
// Add a valid signature to the backup
fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
overwriteRoutes: true,
});
// check that signalling is working
const backupPromise = new Promise<void>((resolve, reject) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});
const validCheck = await aliceCrypto.checkKeyBackupAndEnable();
expect(validCheck?.trustInfo?.trusted).toStrictEqual(true);
await backupPromise;
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
});
describe("isKeyBackupTrusted", () => {
it("does not trust a backup signed by an untrusted device", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// download the device list, to match the trusted case
await aliceClient.startClient();
await waitForDeviceList();
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
});
it("trusts a backup signed by a trusted device", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// tell Alice to trust the dummy device that signed the backup
await aliceClient.startClient();
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
expect(result).toEqual({ trusted: true, matchesDecryptionKey: false });
});
it("recognises a backup which matches the decryption key", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
);
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
expect(result).toEqual({ trusted: false, matchesDecryptionKey: true });
});
it("is not fooled by a backup which matches the decryption key but uses a different algorithm", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
);
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
backup.algorithm = "m.megolm_backup.v1.aes-hmac-sha2";
const result = await aliceCrypto.isKeyBackupTrusted(backup);
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
});
});
describe("checkKeyBackupAndEnable", () => {
it("enables a backup signed by a trusted device", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// tell Alice to trust the dummy device that signed the backup
await aliceClient.startClient();
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
expect(result!.trustInfo).toEqual({ trusted: true, matchesDecryptionKey: false });
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
});
it("does not enable a backup signed by an untrusted device", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// download the device list, to match the trusted case
await aliceClient.startClient();
await waitForDeviceList();
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
expect(result!.trustInfo).toEqual({ trusted: false, matchesDecryptionKey: false });
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
});
it("disables backup when a new untrusted backup is available", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// tell Alice to trust the dummy device that signed the backup
await aliceClient.startClient();
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
delete unsignedBackup.auth_data.signatures;
unsignedBackup.version = "2";
fetchMock.get("path:/_matrix/client/v3/room_keys/version", unsignedBackup, {
overwriteRoutes: true,
});
await aliceCrypto.checkKeyBackupAndEnable();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
});
it("switches backup when a new trusted backup is available", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// tell Alice to trust the dummy device that signed the backup
await aliceClient.startClient();
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
const newBackupVersion = "2";
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
newBackup.version = newBackupVersion;
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
overwriteRoutes: true,
});
await aliceCrypto.checkKeyBackupAndEnable();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(newBackupVersion);
});
it("Disables when backup is deleted", async () => {
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
// tell Alice to trust the dummy device that signed the backup
await aliceClient.startClient();
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
fetchMock.get(
"path:/_matrix/client/v3/room_keys/version",
{
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "No backup found",
},
},
{
overwriteRoutes: true,
},
);
const noResult = await aliceCrypto.checkKeyBackupAndEnable();
expect(noResult).toBeNull();
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
});
});
/** make sure that the client knows about the dummy device */
async function waitForDeviceList(): Promise<void> {
// Completing the initial sync will make the device list download outdated device lists (of which our own
// user will be one).
syncResponder.sendOrQueueSyncResponse({});
// DeviceList has a sleep(5) which we need to make happen
await jest.advanceTimersByTimeAsync(10);
// The client should now know about the dummy device
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID);
}
});
+344 -26
View File
@@ -22,7 +22,15 @@ import fetchMock from "fetch-mock-jest";
import { IDBFactory } from "fake-indexeddb";
import { createHash } from "crypto";
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src";
import {
createClient,
CryptoEvent,
IContent,
ICreateClientOpts,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
} from "../../../src";
import {
canAcceptVerificationRequest,
ShowQrCodeCallbacks,
@@ -34,15 +42,20 @@ import {
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, emitPromise, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
BOB_SIGNED_TEST_DEVICE_DATA,
BOB_TEST_USER_ID,
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
TEST_DEVICE_ID,
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
TEST_ROOM_ID,
TEST_USER_ID,
BOB_ONE_TIME_KEYS,
} from "../../test-utils/test-data";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
@@ -59,7 +72,7 @@ beforeAll(async () => {
// load the rust library. This can take a few seconds on a slow GH worker.
beforeAll(async () => {
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-js");
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm");
await RustSdkCryptoJs.initAsync();
}, 10000);
@@ -81,9 +94,9 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
*/
// we test with both crypto stacks...
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
// backend. Once we drop support for legacy crypto, it will go away.
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
/** the client under test */
let aliceClient: MatrixClient;
@@ -124,7 +137,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
describe("Outgoing verification requests for another device", () => {
beforeEach(async () => {
// pretend that we have another device, which we will verify
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
});
// test with (1) the default verification method list, (2) a custom verification method list.
@@ -164,6 +177,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(requests[0].transactionId).toEqual(transactionId);
}
// check that the returned request depends on the given userID
{
const requests = aliceClient
.getCrypto()!
.getVerificationRequestsToDeviceInProgress("@unknown:localhost");
expect(requests.length).toEqual(0);
}
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
@@ -387,12 +408,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
});
oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => {
it("can verify another via QR code with an untrusted cross-signing key", async () => {
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);
await waitForDeviceList();
expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy();
// have alice initiate a verification. She should send a m.key.verification.request
const [requestBody, request] = await Promise.all([
@@ -414,7 +434,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(request.phase).toEqual(VerificationPhase.Ready);
// we should now have QR data we can display
const qrCodeBuffer = request.getQRCodeBytes()!;
const qrCodeBuffer = (await request.generateQRCode())!;
expect(qrCodeBuffer).toBeTruthy();
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
@@ -430,16 +450,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
);
const sharedSecret = qrCodeBuffer.subarray(74 + txnIdLen);
// we should still be "Ready" and have no verifier
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.verifier).toBeUndefined();
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
returnToDeviceMessageFromSync({
type: "m.key.verification.start",
content: {
from_device: TEST_DEVICE_ID,
method: "m.reciprocate.v1",
transaction_id: transactionId,
secret: encodeUnpaddedBase64(sharedSecret),
},
});
returnToDeviceMessageFromSync(buildReciprocateStartMessage(transactionId, sharedSecret));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Started);
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
@@ -447,34 +463,184 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
// there should now be a verifier
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
// ... which we call .verify on, which emits a ShowReciprocateQr event
const verificationPromise = verifier.verify();
const reciprocateQRCodeCallbacks = await new Promise<ShowQrCodeCallbacks>((resolve) => {
const reciprocatePromise = new Promise<ShowQrCodeCallbacks>((resolve) => {
verifier.once(VerifierEvent.ShowReciprocateQr, resolve);
});
const verificationPromise = verifier.verify();
const reciprocateQRCodeCallbacks = await reciprocatePromise;
// getReciprocateQrCodeCallbacks() is an alternative way to get the callbacks
expect(verifier.getReciprocateQrCodeCallbacks()).toBe(reciprocateQRCodeCallbacks);
expect(verifier.getShowSasCallbacks()).toBeNull();
// Alice confirms she is happy
// Alice confirms she is happy, which makes her reply with a 'done'
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.done");
reciprocateQRCodeCallbacks.confirm();
await sendToDevicePromise;
// that should satisfy Alice, who should reply with a 'done'
// the dummy device replies with its own 'done'
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
// ... and the whole thing should be done!
await verificationPromise;
expect(request.phase).toEqual(VerificationPhase.Done);
});
newBackendOnly("can verify another by scanning their QR code", 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 show a QR code
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"]));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.otherPartySupportsMethod("m.qr_code.show.v1")).toBe(true);
// the dummy device shows a QR code
const sharedSecret = "SUPERSEKRET";
const qrCodeBuffer = buildQRCode(
transactionId,
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
sharedSecret,
);
// Alice scans the QR code
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
const verifier = await request.scanQRCode(qrCodeBuffer);
const requestBody = await sendToDevicePromise;
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage).toEqual({
from_device: aliceClient.deviceId,
method: "m.reciprocate.v1",
transaction_id: transactionId,
secret: encodeUnpaddedBase64(Buffer.from(sharedSecret)),
});
expect(request.phase).toEqual(VerificationPhase.Started);
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
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;
expect(request.phase).toEqual(VerificationPhase.Done);
});
it("can send an SAS start after QR code display", async () => {
aliceClient = await startTestClient();
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, with an indication it can scan a QR code
// or do the emoji dance
returnToDeviceMessageFromSync(
buildReadyMessage(transactionId, ["m.qr_code.scan.v1", "m.sas.v1", "m.reciprocate.v1"]),
);
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
// Alice displays the QR code
const qrCodeBuffer = (await request.generateQRCode())!;
expect(qrCodeBuffer).toBeTruthy();
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.verifier).toBeUndefined();
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
await jest.advanceTimersByTimeAsync(10);
// ... but Alice wants to do an SAS verification
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
await request.startVerification("m.sas.v1");
await sendToDevicePromise;
// There should now be a `verifier`
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(request.chosenMethod).toEqual("m.sas.v1");
// clean up the test
expectSendToDeviceMessage("m.key.verification.cancel");
request.cancel();
await expect(verifier.verify()).rejects.toBeTruthy();
});
it("can receive an SAS start after QR code display", async () => {
aliceClient = await startTestClient();
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, with an indication it can scan a QR code
// or do the emoji dance
returnToDeviceMessageFromSync(
buildReadyMessage(transactionId, ["m.qr_code.scan.v1", "m.sas.v1", "m.reciprocate.v1"]),
);
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
// Alice displays the QR code
const qrCodeBuffer = (await request.generateQRCode())!;
expect(qrCodeBuffer).toBeTruthy();
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.verifier).toBeUndefined();
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
await jest.advanceTimersByTimeAsync(10);
// ... but the dummy device wants to do an SAS verification
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
await emitPromise(request, VerificationRequestEvent.Change);
// Alice should now have a `verifier`
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(request.chosenMethod).toEqual("m.sas.v1");
// clean up the test
expectSendToDeviceMessage("m.key.verification.cancel");
request.cancel();
await expect(verifier.verify()).rejects.toBeTruthy();
});
});
describe("cancellation", () => {
beforeEach(async () => {
// pretend that we have another device, which we will start verifying
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
aliceClient = await startTestClient();
await waitForDeviceList();
@@ -542,11 +708,55 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(request.phase).toEqual(VerificationPhase.Cancelled);
expect(verifier.hasBeenCancelled).toBe(true);
});
it("can cancel in the ShowQrCodeCallbacks", async () => {
// have alice initiate a verification. She should send 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, with an indication it can scan the QR code
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.scan.v1"]));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
// we should now have QR data we can display
const qrCodeBuffer = (await request.generateQRCode())!;
expect(qrCodeBuffer).toBeTruthy();
const sharedSecret = qrCodeBuffer.subarray(74 + transactionId.length);
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
returnToDeviceMessageFromSync(buildReciprocateStartMessage(transactionId, sharedSecret));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Started);
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
// there should now be a verifier
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
// ... which we call .verify on, which emits a ShowReciprocateQr event
const reciprocatePromise = emitPromise(verifier, VerifierEvent.ShowReciprocateQr);
const verificationPromise = verifier.verify();
const reciprocateQRCodeCallbacks: ShowQrCodeCallbacks = await reciprocatePromise;
// Alice complains that she didn't see the dummy device scan her code
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.cancel");
reciprocateQRCodeCallbacks.cancel();
await sendToDevicePromise;
// ... which should cancel the verifier
await expect(verificationPromise).rejects.toBeTruthy();
expect(request.phase).toEqual(VerificationPhase.Cancelled);
expect(verifier.hasBeenCancelled).toBe(true);
});
});
describe("Incoming verification from another device", () => {
beforeEach(async () => {
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
aliceClient = await startTestClient();
await waitForDeviceList();
@@ -611,6 +821,82 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
});
});
describe("Send verification request in DM", () => {
beforeEach(async () => {
aliceClient = await startTestClient();
aliceClient.setGlobalErrorOnUnknownDevices(false);
e2eKeyResponder.addCrossSigningData(BOB_SIGNED_CROSS_SIGNING_KEYS_DATA);
e2eKeyResponder.addDeviceKeys(BOB_SIGNED_TEST_DEVICE_DATA);
syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID]));
// Wait for the sync response to be processed
await syncPromise(aliceClient);
});
/**
* Create a mock to respond when the verification request is sent
* Handle both encrypted and unencrypted requests
*/
function awaitRoomMessageRequest(): Promise<IContent> {
return new Promise((resolve) => {
// Case of unencrypted message of the new crypto
fetchMock.put(
"express:/_matrix/client/v3/rooms/:roomId/send/m.room.message/:txId",
(url: string, options: RequestInit) => {
resolve(JSON.parse(options.body as string));
return { event_id: "$YUwRidLecu:example.com" };
},
);
// Case of encrypted message of the old crypto
fetchMock.put(
"express:/_matrix/client/v3/rooms/:roomId/send/m.room.encrypted/:txId",
async (url: string, options: RequestInit) => {
const encryptedMessage = JSON.parse(options.body as string);
const event = new MatrixEvent({
content: encryptedMessage,
type: "m.room.encrypted",
room_id: TEST_ROOM_ID,
});
// Try to decrypt the event
event.once(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, error?: Error) => {
expect(error).not.toBeDefined();
resolve(decryptedEvent.getContent());
});
await aliceClient.decryptEventIfNeeded(event);
return { event_id: "$YUwRidLecu:example.com" };
},
);
});
}
it("alice sends a verification request in a DM to bob", async () => {
fetchMock.post("express:/_matrix/client/v3/keys/claim", () => ({ one_time_keys: BOB_ONE_TIME_KEYS }));
// In `DeviceList#doQueuedQueries`, the key download response is processed every 5ms
// 5ms by users, ie Bob and Alice
await jest.advanceTimersByTimeAsync(10);
const messageRequestPromise = awaitRoomMessageRequest();
const verificationRequest = await aliceClient
.getCrypto()!
.requestVerificationDM(BOB_TEST_USER_ID, TEST_ROOM_ID);
const requestContent = await messageRequestPromise;
expect(requestContent.from_device).toBe(aliceClient.getDeviceId());
expect(requestContent.methods.sort()).toStrictEqual(
["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"].sort(),
);
expect(requestContent.msgtype).toBe("m.key.verification.request");
expect(requestContent.to).toBe(BOB_TEST_USER_ID);
expect(verificationRequest.roomId).toBe(TEST_ROOM_ID);
expect(verificationRequest.isSelfVerification).toBe(false);
expect(verificationRequest.otherUserId).toBe(BOB_TEST_USER_ID);
});
});
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
const client = createClient({
baseUrl: TEST_HOMESERVER_URL,
@@ -717,6 +1003,19 @@ function buildReadyMessage(transactionId: string, methods: string[]): { type: st
};
}
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
function buildReciprocateStartMessage(transactionId: string, sharedSecret: Uint8Array) {
return {
type: "m.key.verification.start",
content: {
from_device: TEST_DEVICE_ID,
method: "m.reciprocate.v1",
transaction_id: transactionId,
secret: encodeUnpaddedBase64(sharedSecret),
},
};
}
/** build an m.key.verification.start to-device message suitable for the SAS flow, originating from the dummy device */
function buildSasStartMessage(transactionId: string): { type: string; content: object } {
return {
@@ -794,3 +1093,22 @@ function buildDoneMessage(transactionId: string) {
},
};
}
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): 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.writeInt16BE(transactionId.length, idx);
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
idx += Buffer.from(key1Base64, "base64").copy(qrCodeBuffer, idx);
idx += Buffer.from(key2Base64, "base64").copy(qrCodeBuffer, idx);
idx += qrCodeBuffer.write(sharedSecret, idx);
// truncate to the right length
return qrCodeBuffer.subarray(0, idx);
}
@@ -207,7 +207,7 @@ function startClient(httpBackend: HttpBackend, client: MatrixClient) {
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
client.startClient();
client.startClient({ threadSupport: true });
// set up a promise which will resolve once the client is initialised
const prom = new Promise<void>((resolve) => {
@@ -248,7 +248,7 @@ describe("getEventTimeline support", function () {
return startClient(httpBackend, client).then(function () {
const room = client.getRoom(roomId)!;
const timelineSet = room!.getTimelineSets()[0];
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
@@ -260,7 +260,18 @@ describe("getEventTimeline support", function () {
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId)!;
const timelineSet = room!.getTimelineSets()[0];
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
event: {
event_id: "event",
},
events_after: [],
events_before: [],
state: [],
}));
return Promise.all([
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
httpBackend.flushAllExpected(),
]);
});
});
@@ -271,7 +282,7 @@ describe("getEventTimeline support", function () {
return startClient(httpBackend, client).then(function () {
const timelineSet = new EventTimelineSet(undefined);
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
@@ -778,7 +789,18 @@ describe("MatrixClient event timelines", function () {
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy();
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
event: {
event_id: "event",
},
events_after: [],
events_before: [],
state: [],
}));
return Promise.all([
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
httpBackend.flushAllExpected(),
]);
});
});
@@ -1262,7 +1284,6 @@ describe("MatrixClient event timelines", function () {
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_ROOT_REACTION.getId(),
THREAD_REPLY3.getId(),
]);
});
@@ -1321,7 +1342,7 @@ describe("MatrixClient event timelines", function () {
function respondToContext(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/r0/rooms/$roomId/context/$eventId", {
encodeUri("/_matrix/client/v3/rooms/$roomId/context/$eventId", {
$roomId: roomId,
$eventId: event.event_id!,
}),
@@ -1339,7 +1360,7 @@ describe("MatrixClient event timelines", function () {
function respondToEvent(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/r0/rooms/$roomId/event/$eventId", {
encodeUri("/_matrix/client/v3/rooms/$roomId/event/$eventId", {
$roomId: roomId,
$eventId: event.event_id!,
}),
@@ -1350,7 +1371,7 @@ describe("MatrixClient event timelines", function () {
function respondToMessagesRequest(): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/r0/rooms/$roomId/messages", {
encodeUri("/_matrix/client/v3/rooms/$roomId/messages", {
$roomId: roomId,
}),
);
@@ -2005,6 +2026,25 @@ describe("MatrixClient event timelines", function () {
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
)
.respond(200, function () {
return {
chunk: [THREAD_REPLY],
};
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
const room = client.getRoom(roomId)!;
File diff suppressed because it is too large Load Diff
+63 -4
View File
@@ -38,6 +38,7 @@ import {
Room,
IndexedDBStore,
RelationType,
EventType,
} from "../../src";
import { ReceiptType } from "../../src/@types/read_receipts";
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
@@ -223,8 +224,6 @@ describe("MatrixClient syncing", () => {
});
it("should honour lazyLoadMembers if user is not a guest", () => {
client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true);
httpBackend!
.when("GET", "/sync")
.check((req) => {
@@ -241,8 +240,6 @@ describe("MatrixClient syncing", () => {
it("should not honour lazyLoadMembers if user is a guest", () => {
httpBackend!.expectedRequests = [];
httpBackend!.when("GET", "/versions").respond(200, {});
client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true);
httpBackend!
.when("GET", "/sync")
.check((req) => {
@@ -1590,6 +1587,68 @@ describe("MatrixClient syncing", () => {
});
});
});
it("should apply encrypted notification logic for events within the same sync blob", async () => {
const roomId = "!room123:server";
const syncData = {
rooms: {
join: {
[roomId]: {
ephemeral: {
events: [],
},
timeline: {
events: [
utils.mkEvent({
room: roomId,
event: true,
skey: "",
type: EventType.RoomEncryption,
content: {},
}),
utils.mkMessage({
room: roomId,
user: otherUserId,
msg: "hello",
}),
],
},
state: {
events: [
utils.mkMembership({
room: roomId,
mship: "join",
user: otherUserId,
}),
utils.mkMembership({
room: roomId,
mship: "join",
user: selfUserId,
}),
utils.mkEvent({
type: "m.room.create",
room: roomId,
user: selfUserId,
content: {
creator: selfUserId,
},
}),
],
},
},
},
},
} as unknown as ISyncResponse;
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
const room = client!.getRoom(roomId)!;
expect(room).toBeInstanceOf(Room);
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
});
});
describe("of a room", () => {
+1 -1
View File
@@ -121,7 +121,7 @@ describe("SlidingSyncSdk", () => {
await client!.initCrypto();
syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
}
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
httpBackend!.when("GET", "/_matrix/client/v3/pushrules").respond(200, {});
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
};
+12 -11
View File
@@ -1161,11 +1161,6 @@ describe("SlidingSync", () => {
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
await httpBackend!.flushAllExpected();
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
// which is a fail.
expect(failPromise).rejects.toEqual(gotTxnIds[0]);
expect(failPromise2).rejects.toEqual(gotTxnIds[1]);
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
let txnId: string | undefined;
httpBackend!
@@ -1180,8 +1175,12 @@ describe("SlidingSync", () => {
txn_id: txnId,
};
});
await httpBackend!.flushAllExpected();
await okPromise;
await Promise.all([
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
httpBackend!.flushAllExpected(),
okPromise,
]);
expect(txnId).toBeDefined();
});
@@ -1200,7 +1199,6 @@ describe("SlidingSync", () => {
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
// which is a fail.
expect(A).rejects.toEqual(gotTxnIds[0]);
const C = slidingSync.setListRanges("a", [[0, 20]]);
let pendingC = true;
@@ -1217,9 +1215,12 @@ describe("SlidingSync", () => {
txn_id: gotTxnIds[1],
};
});
await httpBackend!.flushAllExpected();
// A is rejected, see above
expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved
await Promise.all([
expect(A).rejects.toEqual(gotTxnIds[0]),
httpBackend!.flushAllExpected(),
// A is rejected, see above
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
]);
expect(pendingC).toBe(true); // C is pending still
});
it("should do nothing for unknown txn_ids", async () => {
-2
View File
@@ -75,8 +75,6 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
const listener = (url: string, options: RequestInit) =>
this.onKeyUploadRequest(resolveOneTimeKeys, options);
// catch both r0 and v3 variants
fetchMock.post(new URL("/_matrix/client/r0/keys/upload", homeserverUrl).toString(), listener);
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
});
}
+2 -6
View File
@@ -43,8 +43,6 @@ export class E2EKeyResponder {
public constructor(homeserverUrl: string) {
// set up a listener for /keys/query.
const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options);
// catch both r0 and v3 variants
fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener);
fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener);
}
@@ -89,12 +87,10 @@ export class E2EKeyResponder {
/**
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
*
* @param userId - user the keys belong to
* @param deviceId - device the keys belong to
* @param keys - device keys for this device.
*/
public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) {
this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys);
public addDeviceKeys(keys: IDeviceKeys) {
this.deviceKeysByUserByDevice.getOrCreate(keys.user_id).set(keys.device_id, keys);
}
/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
+1 -1
View File
@@ -75,7 +75,7 @@ export class SyncResponder implements ISyncResponder {
*/
public constructor(homeserverUrl: string) {
this.debug = debugFunc(`sync-responder:[${homeserverUrl}]`);
fetchMock.get("begin:" + new URL("/_matrix/client/r0/sync?", homeserverUrl).toString(), (_url, _options) =>
fetchMock.get("begin:" + new URL("/_matrix/client/v3/sync?", homeserverUrl).toString(), (_url, _options) =>
this.onSyncRequest(),
);
}
-1
View File
@@ -86,7 +86,6 @@ export const mockClientMethodsEvents = () => ({
* Returns basic mocked client methods related to server support
*/
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
doesServerSupportSeparateAddAndBind: jest.fn(),
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}),
+8 -5
View File
@@ -22,9 +22,9 @@ import fetchMock from "fetch-mock-jest";
* @param homeserverUrl - the homeserver url for the client under test
*/
export function mockInitialApiRequests(homeserverUrl: string) {
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["v1.1"] });
fetchMock.getOnce(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
fetchMock.postOnce(new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
}
@@ -32,13 +32,16 @@ export function mockInitialApiRequests(homeserverUrl: string) {
/**
* Mock the requests needed to set up cross signing
*
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
* Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
*/
export function mockSetupCrossSigningRequests(): void {
// have account_data requests return an empty object
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
fetchMock.get("express:/_matrix/client/v3/user/:userId/account_data/:type", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
});
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
+53
View File
@@ -0,0 +1,53 @@
/*
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 { OidcClientConfig } from "../../src";
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
/**
* Makes a valid OidcClientConfig with minimum valid values
* @param issuer used as the base for all other urls
* @returns OidcClientConfig
*/
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
const metadata = mockOpenIdConfiguration(issuer);
return {
issuer,
account: issuer + "account",
registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint,
metadata,
};
};
/**
* Useful for mocking <issuer>/.well-known/openid-configuration
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
authorization_endpoint: issuer + "auth",
registration_endpoint: issuer + "registration",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
});
+234 -65
View File
@@ -28,50 +28,45 @@ import base64
import json
from canonicaljson import encode_canonical_json
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from random import randbytes, seed
# input data
TEST_USER_ID = "@alice:localhost"
TEST_DEVICE_ID = "test_device"
# any 32-byte string can be an ed25519 private key.
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
ALICE_DATA = {
"TEST_USER_ID": "@alice:localhost",
"TEST_DEVICE_ID": "test_device",
"TEST_ROOM_ID": "!room:id",
# any 32-byte string can be an ed25519 private key.
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"deadbeefdeadbeefdeadbeefdeadbeef",
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"doyouspeakwhaaaaaaaaaaaaaaaaaale",
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"useruseruseruseruseruseruseruser",
"SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"selfselfselfselfselfselfselfself",
# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
"B64_BACKUP_DECRYPTION_KEY": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=",
"OTK": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw"
}
BOB_DATA = {
"TEST_USER_ID": "@bob:xyz",
"TEST_DEVICE_ID": "bob_device",
"TEST_ROOM_ID": "!room:id",
# any 32-byte string can be an ed25519 private key.
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"Deadbeefdeadbeefdeadbeefdeadbeef",
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Doyouspeakwhaaaaaaaaaaaaaaaaaale",
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Useruseruseruseruseruseruseruser",
"SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Selfselfselfselfselfselfselfself",
# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
"B64_BACKUP_DECRYPTION_KEY": "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=",
"OTK": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw"
}
def main() -> None:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
TEST_DEVICE_PRIVATE_KEY_BYTES
)
b64_public_key = encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
device_data = {
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"device_id": TEST_DEVICE_ID,
"keys": {
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
},
"signatures": {TEST_USER_ID: {}},
"user_id": TEST_USER_ID,
}
device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
device_data, private_key
)
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
print(
f"""\
/* Test data for cryptography tests
@@ -79,42 +74,183 @@ def main() -> None:
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult }} from "../../../src";
import {{ KeyBackupInfo }} from "../../../src/crypto-api";
/* eslint-disable comma-dangle */
export const TEST_USER_ID = "{TEST_USER_ID}";
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
// Alice data
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
{build_test_data(ALICE_DATA)}
// Bob data
/** Signed device data, suitable for returning from a `/keys/query` call */
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
/** base64-encoded public master cross-signing key */
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
json.dumps(build_cross_signing_keys_data(), indent=4)
};
{build_test_data(BOB_DATA, "BOB_")}
""",
end="",
)
# Use static seed to have stable random test data upon new generation
seed(10)
def build_cross_signing_keys_data() -> dict:
def build_test_data(user_data, prefix = "") -> str:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
user_data["TEST_DEVICE_PRIVATE_KEY_BYTES"]
)
b64_public_key = encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
device_data = {
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"device_id": user_data["TEST_DEVICE_ID"],
"keys": {
f"curve25519:{user_data['TEST_DEVICE_ID']}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
f"ed25519:{user_data['TEST_DEVICE_ID']}": b64_public_key,
},
"signatures": {user_data['TEST_USER_ID']: {}},
"user_id": user_data["TEST_USER_ID"],
}
device_data["signatures"][user_data["TEST_USER_ID"]][f"ed25519:{user_data['TEST_DEVICE_ID']}"] = sign_json(
device_data, private_key
)
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
b64_master_private_key = encode_base64(user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_self_signing_public_key = encode_base64(
self_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
b64_self_signing_private_key = encode_base64( user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_user_signing_public_key = encode_base64(
user_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
b64_user_signing_private_key = encode_base64(user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
backup_decryption_key = x25519.X25519PrivateKey.from_private_bytes(
base64.b64decode(user_data["B64_BACKUP_DECRYPTION_KEY"])
)
b64_backup_public_key = encode_base64(
backup_decryption_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
backup_data = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": b64_backup_public_key,
},
}
# sign with our device key
sig = sign_json(backup_data["auth_data"], private_key)
backup_data["auth_data"]["signatures"] = {
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": sig}
}
set_of_exported_room_keys = [build_exported_megolm_key(), build_exported_megolm_key()]
additional_exported_room_key = build_exported_megolm_key()
otk_to_sign = {
"key": user_data['OTK']
}
# sign our public otk key with our device key
otk = sign_json(otk_to_sign, private_key)
otks = {
user_data["TEST_USER_ID"]: {
user_data['TEST_DEVICE_ID']: {
"signed_curve25519:AAAAHQ": {
"key": user_data["OTK"],
"signatures": {
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": otk}
}
}
}
}
}
return f"""\
export const {prefix}TEST_USER_ID = "{user_data['TEST_USER_ID']}";
export const {prefix}TEST_DEVICE_ID = "{user_data['TEST_DEVICE_ID']}";
export const {prefix}TEST_ROOM_ID = "{user_data['TEST_ROOM_ID']}";
/** The base64-encoded public ed25519 key for this device */
export const {prefix}TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const {prefix}SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
/** base64-encoded public master cross-signing key */
export const {prefix}MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
/** base64-encoded private master cross-signing key */
export const {prefix}MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_master_private_key}";
/** base64-encoded public self cross-signing key */
export const {prefix}SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_self_signing_public_key}";
/** base64-encoded private self signing cross-signing key */
export const {prefix}SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_self_signing_private_key}";
/** base64-encoded public user cross-signing key */
export const {prefix}USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_user_signing_public_key}";
/** base64-encoded private user signing cross-signing key */
export const {prefix}USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_user_signing_private_key}";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
json.dumps(build_cross_signing_keys_data(user_data), indent=4)
};
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
json.dumps(set_of_exported_room_keys, indent=4)
};
/** An exported megolm session */
export const {prefix}MEGOLM_SESSION_DATA: IMegolmSessionData = {
json.dumps(additional_exported_room_key, indent=4)
};
/** Signed OTKs, returned by `POST /keys/claim` */
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
"""
def build_cross_signing_keys_data(user_data) -> dict:
"""Build the signed cross-signing-keys data for return from /keys/query"""
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_self_signing_public_key = encode_base64(
self_signing_private_key.public_key().public_bytes(
@@ -122,7 +258,7 @@ def build_cross_signing_keys_data() -> dict:
)
)
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
)
b64_user_signing_public_key = encode_base64(
user_signing_private_key.public_key().public_bytes(
@@ -132,39 +268,39 @@ def build_cross_signing_keys_data() -> dict:
# create without signatures initially
cross_signing_keys_data = {
"master_keys": {
TEST_USER_ID: {
user_data["TEST_USER_ID"]: {
"keys": {
f"ed25519:{b64_master_public_key}": b64_master_public_key,
},
"user_id": TEST_USER_ID,
"user_id": user_data["TEST_USER_ID"],
"usage": ["master"],
}
},
"self_signing_keys": {
TEST_USER_ID: {
user_data["TEST_USER_ID"]: {
"keys": {
f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key,
},
"user_id": TEST_USER_ID,
"user_id": user_data["TEST_USER_ID"],
"usage": ["self_signing"],
},
},
"user_signing_keys": {
TEST_USER_ID: {
user_data["TEST_USER_ID"]: {
"keys": {
f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key,
},
"user_id": TEST_USER_ID,
"user_id": user_data["TEST_USER_ID"],
"usage": ["user_signing"],
},
},
}
# sign the sub-keys with the master
for k in ["self_signing_keys", "user_signing_keys"]:
to_sign = cross_signing_keys_data[k][TEST_USER_ID]
to_sign = cross_signing_keys_data[k][user_data["TEST_USER_ID"]]
sig = sign_json(to_sign, master_private_key)
to_sign["signatures"] = {
TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig}
user_data["TEST_USER_ID"]: {f"ed25519:{b64_master_public_key}": sig}
}
return cross_signing_keys_data
@@ -198,6 +334,39 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
return signature_base64
def build_exported_megolm_key() -> dict:
"""
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
that can be imported via importRoomKeys API.
"""
index = 0
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32))
# Just use radom bytes for the ratchet parts
ratchet = randbytes(32 * 4)
# exported key, start with version byte
exported_key = bytearray(b'\x01')
exported_key += index.to_bytes(4, 'big')
exported_key += ratchet
# KPub
exported_key += private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
megolm_export = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
"session_key": encode_base64(exported_key),
"sender_claimed_keys": {
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
},
"forwarding_curve25519_key_chain": [],
}
return megolm_export
if __name__ == "__main__":
main()
+258 -1
View File
@@ -3,13 +3,17 @@
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { IDeviceKeys } from "../../../src/@types/crypto";
import { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
import { IDownloadKeyResult } from "../../../src";
import { KeyBackupInfo } from "../../../src/crypto-api";
/* eslint-disable comma-dangle */
// Alice data
export const TEST_USER_ID = "@alice:localhost";
export const TEST_DEVICE_ID = "test_device";
export const TEST_ROOM_ID = "!room:id";
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
@@ -36,6 +40,21 @@ export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
/** base64-encoded public master cross-signing key */
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY";
/** base64-encoded private master cross-signing key */
export const MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "ZG95b3VzcGVha3doYWFhYWFhYWFhYWFhYWFhYWFhbGU";
/** base64-encoded public self cross-signing key */
export const SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY";
/** base64-encoded private self signing cross-signing key */
export const SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "c2VsZnNlbGZzZWxmc2VsZnNlbGZzZWxmc2VsZnNlbGY";
/** base64-encoded public user cross-signing key */
export const USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY";
/** base64-encoded private user signing cross-signing key */
export const USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "dXNlcnVzZXJ1c2VydXNlcnVzZXJ1c2VydXNlcnVzZXI";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
@@ -82,3 +101,241 @@ export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
}
}
};
/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
},
"forwarding_curve25519_key_chain": []
},
{
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "mPYSGA2l1tOQiipEDEVYhDSdTSFh2lDW1qpGKYZRxTc",
"session_key": "AQAAAAAHwgkB49BTPAEGTCK6degxUIbl8GPG2ugPRYhNtOpNic63u11+baXFfjDw5fmVfD1gJXpQQjGsqrIYioxrB1xzl7mfb942UHhYdaMQZowpp1fSpJVsxR5TddUU2EWifYD9EQsoz8mY1zqoazm4vUP4v9yxaTcUBj2c6HMJCY0gCJj2EhgNpdbTkIoqRAxFWIQ0nU0hYdpQ1taqRimGUcU3",
"sender_claimed_keys": {
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
},
"forwarding_curve25519_key_chain": []
}
];
/** An exported megolm session */
export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
"session_key": "AQAAAABXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIXBlK8tAfJo3cJnlh7F4ltEOAqrdME6dU0zXTkqXmURqYqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
"sender_claimed_keys": {
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
},
"forwarding_curve25519_key_chain": []
};
/** Signed OTKs, returned by `POST /keys/claim` */
export const ONE_TIME_KEYS = {
"@alice:localhost": {
"test_device": {
"signed_curve25519:AAAAHQ": {
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "25djC6Rk6gIgFBMVawY9X9LnY8XMMziey6lKqL8Q5Bbp7T1vw9uk0RE7eKO2a/jNLcYroO2xRztGhBrKz5sOCQ"
}
}
}
}
}
};
// Bob data
export const BOB_TEST_USER_ID = "@bob:xyz";
export const BOB_TEST_DEVICE_ID = "bob_device";
export const BOB_TEST_ROOM_ID = "!room:id";
/** The base64-encoded public ed25519 key for this device */
export const BOB_TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "jmY0h8QS6Te6gxyjOmMc0eKOqmbAtXpVo4CCWFubk50";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const BOB_SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"device_id": "bob_device",
"keys": {
"curve25519:bob_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
"ed25519:bob_device": "jmY0h8QS6Te6gxyjOmMc0eKOqmbAtXpVo4CCWFubk50"
},
"user_id": "@bob:xyz",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "4ApBs9jaeGyfdYaWRUdBvQAkDyXjACJ9KJ0xLHMgiFT/1yo6VqPTx2iziKGnrBiGhbtKNxEhDPOvZZkBU73cDQ"
}
}
};
/** base64-encoded public master cross-signing key */
export const BOB_MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA";
/** base64-encoded private master cross-signing key */
export const BOB_MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "RG95b3VzcGVha3doYWFhYWFhYWFhYWFhYWFhYWFhbGU";
/** base64-encoded public self cross-signing key */
export const BOB_SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A";
/** base64-encoded private self signing cross-signing key */
export const BOB_SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "U2VsZnNlbGZzZWxmc2VsZnNlbGZzZWxmc2VsZnNlbGY";
/** base64-encoded public user cross-signing key */
export const BOB_USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw";
/** base64-encoded private user signing cross-signing key */
export const BOB_USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "VXNlcnVzZXJ1c2VydXNlcnVzZXJ1c2VydXNlcnVzZXI";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const BOB_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
"@bob:xyz": {
"keys": {
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA"
},
"user_id": "@bob:xyz",
"usage": [
"master"
]
}
},
"self_signing_keys": {
"@bob:xyz": {
"keys": {
"ed25519:DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A": "DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A"
},
"user_id": "@bob:xyz",
"usage": [
"self_signing"
],
"signatures": {
"@bob:xyz": {
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "RxM8iJU6ZkyzQSVtNnXIJMPyEahVsN+fQQTBNKAs+kqySFyXBgchx+8czZaAhJCpXh9gD1nskT4yyFd2eyUXBw"
}
}
}
},
"user_signing_keys": {
"@bob:xyz": {
"keys": {
"ed25519:lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw": "lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw"
},
"user_id": "@bob:xyz",
"usage": [
"user_signing"
],
"signatures": {
"@bob:xyz": {
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "jF8fvnPZulrPyh/4E8dNDVBP3iHHl9bRc+rRArVyGzoom+uVrokOck7BN2YmPyCRFZJJx7fgRA1Bveyu+mTVAg"
}
}
}
}
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "X9SK5JceUdvUr9gZkmdfS78qmKztoL60oORJ9JA5XD8",
"session_key": "AQAAAADbfOdUj/ec5bK4xVEhhRw+jfd1FD4uA0MLy7NyVHugZxyXNZUx5YEof4H9YyjBretviZreMSXqflbgYKz257rkKu7MPeKFf7zmln2GxX0F/p++GOnvpY1FqOglhfRQi3tqiyOa7SL4f7TuERDTOpMqlWhIfTKQnqy0AyF2vpDi5V/UiuSXHlHb1K/YGZJnX0u/Kpis7aC+tKDkSfSQOVw/",
"sender_claimed_keys": {
"ed25519": "ZG6lrfATe+958wN1xaGf3dKG/CThEfkmNdp1jcu4zok"
},
"forwarding_curve25519_key_chain": []
},
{
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA",
"session_key": "AQAAAACv0khqPrQ91MmWCgm0RTzfpn65AGCrRnAKLxGJdfSfECNZ8gyj34FZLwi+F+xC6ibFddcbLXW0mzR6PnTnHF3VHM4g/h+2rcxtlix8fySpIwFzaXViba7cOSy/b+dHTMZB40iA7F4y7AdTdHLv4N1XUj3puU/KVUIKf9/lEDLqyReD+39WdEY24mTIB5NcQQhtyguPzYPT5sSyeIUNd7Bw",
"sender_claimed_keys": {
"ed25519": "HxUKnGfeUu0fF3cLyCFSDXYtVCQHy/+33q9RkzKfsiU"
},
"forwarding_curve25519_key_chain": []
}
];
/** An exported megolm session */
export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"session_id": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM",
"session_key": "AQAAAACvcoGk7mOY59fOqZaxFUiTCBRV1Ia94KBjAZx6kgdgBtkkvs50z8od8/Nc9ncK2UsEiXNvCTTp2dlN3du+Rx0/m7vet2ZOEEp2oYDjHMLLFmwd1gtlGuWYPdXA6Y1+9Yyph0/EDVfS+zd3XvbL0QgbyL43+yQnFNHKlxVJX1eiKTrGTHQtYEOZz6/i/bbk+sV7GhSZFT5IMT9hXsRxdf0D",
"sender_claimed_keys": {
"ed25519": "dV0TIhhkToXpL+gZLo+zXDHJfw7MWYxpg80cynIQDv0"
},
"forwarding_curve25519_key_chain": []
};
/** Signed OTKs, returned by `POST /keys/claim` */
export const BOB_ONE_TIME_KEYS = {
"@bob:xyz": {
"bob_device": {
"signed_curve25519:AAAAHQ": {
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "dlZc9VA/hP980Mxvu9qwi0qJx8VK7sADGOM48CE01YM7K/Mbty9lis/QjtQAWqDg371QyynVRjEzt9qj7eSFCg"
}
}
}
}
}
};
+66 -1
View File
@@ -6,9 +6,19 @@ import "../olm-loader";
import { logger } from "../../src/logger";
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
import {
ClientEvent,
EventType,
IJoinedRoom,
IPusher,
ISyncResponse,
MatrixClient,
MsgType,
RelationType,
} from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
import { TEST_ROOM_ID } from "./test-data";
/**
* Return a promise that is resolved when the client next emits a
@@ -39,6 +49,61 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
});
}
/**
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
* @param roomMembers
* @param roomId
*
* @returns the sync response
*/
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
const roomResponse: IJoinedRoom = {
summary: {
"m.heroes": [],
"m.joined_member_count": roomMembers.length,
"m.invited_member_count": roomMembers.length,
},
state: {
events: [
mkEventCustom({
sender: roomMembers[0],
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
}),
],
},
timeline: {
events: [],
prev_batch: "",
},
ephemeral: { events: [] },
account_data: { events: [] },
unread_notifications: {},
};
for (let i = 0; i < roomMembers.length; i++) {
roomResponse.state.events.push(
mkMembershipCustom({
membership: "join",
sender: roomMembers[i],
}),
);
}
return {
next_batch: "1",
rooms: {
join: { [roomId]: roomResponse },
invite: {},
leave: {},
},
account_data: { events: [] },
};
}
/**
* Create a spy for an object and automatically spy its methods.
* @param constr - The class constructor (used with 'new')
+2 -2
View File
@@ -18,7 +18,7 @@ import { RelationType } from "../../src/@types/event";
import { MatrixClient } from "../../src/client";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import { Thread } from "../../src/models/thread";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
import { mkMessage } from "./test-utils";
export const makeThreadEvent = ({
@@ -34,7 +34,7 @@ export const makeThreadEvent = ({
...props,
relatesTo: {
event_id: rootEventId,
rel_type: "m.thread",
rel_type: THREAD_RELATION_TYPE.name,
["m.in_reply_to"]: {
event_id: replyToEventId,
},
+123 -10
View File
@@ -15,10 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import MockHttpBackend from "matrix-mock-request";
import { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src";
import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcError } from "../../src/oidc/error";
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
// keep to reset the fetch function after using MockHttpBackend
// @ts-ignore private property
const realAutoDiscoveryFetch: typeof global.fetch = AutoDiscovery.fetchFn;
describe("AutoDiscovery", function () {
const getHttpBackend = (): MockHttpBackend => {
@@ -27,6 +34,10 @@ describe("AutoDiscovery", function () {
return httpBackend;
};
afterAll(() => {
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
});
it("should throw an error when no domain is specified", function () {
getHttpBackend();
return Promise.all([
@@ -340,7 +351,7 @@ describe("AutoDiscovery", function () {
function () {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
not_matrix_versions: ["r0.0.1"],
not_matrix_versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -377,7 +388,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -417,7 +428,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -458,7 +469,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -504,7 +515,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -549,7 +560,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -595,7 +606,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -642,7 +653,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -686,7 +697,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -736,7 +747,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -855,4 +866,106 @@ describe("AutoDiscovery", function () {
}),
]);
});
it("should FAIL_ERROR for unsupported Matrix version", () => {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "https://example.org",
},
});
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.6.0"],
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_HOMESERVER_TOO_OLD,
base_url: "https://example.org",
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
describe("m.authentication", () => {
const homeserverName = "example.org";
const homeserverUrl = "https://chat.example.org/";
const issuer = "https://auth.org/";
beforeAll(() => {
// make these tests independent from fetch mocking above
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
});
beforeEach(() => {
fetchMock.resetBehavior();
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
fetchMock.get("https://example.org/.well-known/matrix/client", {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
issuer,
},
});
});
it("should return valid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.SUCCESS,
...config,
signingKeys: [],
account: undefined,
error: null,
});
});
it("should set state to error for invalid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
// authorization_code is required
config.metadata.grant_types_supported = ["openid"];
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.FAIL_ERROR,
error: OidcError.OpSupport,
});
});
});
});
+4 -4
View File
@@ -33,7 +33,7 @@ describe("ContentRepo", function () {
it("should return a download URL if no width/height/resize are specified", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
baseUrl + "/_matrix/media/v3/download/server.name/resourceid",
);
});
@@ -44,21 +44,21 @@ describe("ContentRepo", function () {
it("should return a thumbnail URL if a width/height/resize is specified", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32&height=64&method=crop",
baseUrl + "/_matrix/media/v3/thumbnail/server.name/resourceid" + "?width=32&height=64&method=crop",
);
});
it("should put fragments from mxc:// URIs after any query parameters", function () {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32#automade",
baseUrl + "/_matrix/media/v3/thumbnail/server.name/resourceid" + "?width=32#automade",
);
});
it("should put fragments from mxc:// URIs at the end of the HTTP URI", function () {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
baseUrl + "/_matrix/media/v3/download/server.name/resourceid#automade",
);
});
});
+5 -7
View File
@@ -129,7 +129,7 @@ describe("DeviceList", function () {
});
});
it("should have an outdated devicelist on an invalidation while an " + "update is in progress", function () {
it("should have an outdated devicelist on an invalidation while an update is in progress", async function () {
const dl = createTestDeviceList();
dl.startTrackingDeviceList("@test1:sw1v.org");
@@ -148,11 +148,8 @@ describe("DeviceList", function () {
dl.invalidateUserDeviceList("@test1:sw1v.org");
dl.refreshOutdatedDeviceLists();
// TODO: Fix this test so we actually await the call and assertions and remove
// the eslint disable, https://github.com/matrix-org/matrix-js-sdk/issues/2977
//
// eslint-disable-next-line jest/valid-expect-in-promise
dl.saveIfDirty()
await dl
.saveIfDirty()
.then(() => {
// the first request completes
queryDefer1.resolve({
@@ -163,12 +160,13 @@ describe("DeviceList", function () {
});
return prom1;
})
.then(() => {
.then(async () => {
// uh-oh; user restarts before second request completes. The new instance
// should know we never got a complete device list.
logger.log("Creating new devicelist to simulate app reload");
downloadSpy.mockReset();
const dl2 = createTestDeviceList();
await dl2.load();
const queryDefer3 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer3.promise);
@@ -6,6 +6,6 @@ exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`]
"params": {
"access_token": "token",
},
"path": "/_matrix/media/r0/upload",
"path": "/_matrix/media/v3/upload",
}
`;
+35 -4
View File
@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
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 { emitPromise } from "../../test-utils/test-utils";
import { QueryDict } from "../../../src/utils";
import { defer, QueryDict } from "../../../src/utils";
import { logger } from "../../../src/logger";
describe("FetchHttpApi", () => {
const baseUrl = "http://baseUrl";
@@ -274,11 +277,13 @@ describe("FetchHttpApi", () => {
];
const runTests = (fetchBaseUrl: string) => {
it.each<TestCase>(testCases)(
"creates url with params %s",
({ path, queryParams, prefix, baseUrl }, result) => {
"creates url with params %s => %s",
({ path, queryParams, prefix, baseUrl }, expected) => {
const api = makeApi(fetchBaseUrl);
expect(api.getUrl(path, queryParams, prefix, baseUrl)).toEqual(new URL(result));
const result = api.getUrl(path, queryParams, prefix, baseUrl);
// we only check the stringified URL, to avoid having the test depend on the internals of URL.
expect(result.toString()).toEqual(expected);
},
);
};
@@ -290,4 +295,30 @@ describe("FetchHttpApi", () => {
runTests(baseUrlWithTrailingSlash);
});
});
it("should not log query parameters", async () => {
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 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(`
[
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
]
`);
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
[
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
]
`);
});
});
+4 -4
View File
@@ -84,7 +84,7 @@ describe("MatrixHttpApi", () => {
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(
Method.Post,
baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token",
baseUrl.toLowerCase() + "/_matrix/media/v3/upload?access_token=token",
);
expect(xhr.setRequestHeader).not.toHaveBeenCalledWith("Authorization");
});
@@ -96,7 +96,7 @@ describe("MatrixHttpApi", () => {
accessToken: "token",
});
upload = api.uploadContent({} as File);
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload");
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
expect(xhr.setRequestHeader).toHaveBeenCalledWith("Authorization", "Bearer token");
});
@@ -105,14 +105,14 @@ describe("MatrixHttpApi", () => {
upload = api.uploadContent({} as File, { name: "name" });
expect(xhr.open).toHaveBeenCalledWith(
Method.Post,
baseUrl.toLowerCase() + "/_matrix/media/r0/upload?filename=name",
baseUrl.toLowerCase() + "/_matrix/media/v3/upload?filename=name",
);
});
it("should allow not sending the filename", () => {
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
upload = api.uploadContent({} as File, { name: "name", includeFilename: false });
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload");
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
});
it("should abort xhr when the upload is aborted", () => {
+37 -1
View File
@@ -375,7 +375,7 @@ describe("InteractiveAuth", () => {
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("No appropriate authentication flow found"));
});
it("should handle unexpected error types without data propery set", async () => {
it("should handle unexpected error types without data property set", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
@@ -559,4 +559,40 @@ describe("InteractiveAuth", () => {
ia.chooseStage();
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
});
it("should fire stateUpdated callback if with error when encountered", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [{ stages: [AuthType.Password] }],
params: {
[AuthType.Password]: { param: "aa" },
},
},
});
// first we expect a call here
stateUpdated.mockImplementation((stage) => {
expect(stage).toEqual(AuthType.Password);
ia.submitAuthDict({
type: AuthType.Password,
});
});
// .. which should trigger a call here
doRequest.mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN", error: "This is an error" }));
await Promise.allSettled([ia.attemptAuth()]);
expect(stateUpdated).toHaveBeenCalledWith("m.login.password", {
errcode: "M_UNKNOWN",
error: "This is an error",
});
});
});
+3 -3
View File
@@ -121,7 +121,7 @@ describe("refreshToken", () => {
body: { errcode: "M_UNRECOGNIZED" },
});
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
});
it("re-raises non-M_UNRECOGNIZED exceptions from /v3", async () => {
@@ -132,7 +132,7 @@ describe("refreshToken", () => {
throw new Error("/v1/refresh unexpectedly called");
});
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
});
it("re-raises non-M_UNRECOGNIZED exceptions from /v1", async () => {
@@ -144,6 +144,6 @@ describe("refreshToken", () => {
});
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), 429);
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
});
});
+1 -1
View File
@@ -201,7 +201,7 @@ describe("MatrixClient", function () {
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve({
unstable_features: unstableFeatures,
versions: ["r0.6.0", "r0.6.1"],
versions: ["v1.1"],
});
}
const next = httpLookups.shift();
+5 -1
View File
@@ -703,7 +703,11 @@ async function createThread(client: MatrixClient, user: string, roomId: string):
root.setThreadId(root.getId());
await room.addLiveEvents([root]);
return room.createThread(root.getId()!, root, [], false);
// Create the thread and wait for it to be initialised
const thread = room.createThread(root.getId()!, root, [], false);
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
return thread;
}
/**
+215 -85
View File
@@ -1,3 +1,7 @@
/**
* @jest-environment jsdom
*/
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
@@ -25,8 +29,10 @@ import {
completeAuthorizationCodeGrant,
generateAuthorizationParams,
generateAuthorizationUrl,
generateOidcAuthorizationUrl,
} from "../../../src/oidc/authorize";
import { OidcError } from "../../../src/oidc/error";
import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc";
jest.mock("jwt-decode");
@@ -34,20 +40,20 @@ jest.mock("jwt-decode");
const realSubtleCrypto = crypto.subtleCrypto;
describe("oidc authorization", () => {
const issuer = "https://auth.com/";
const authorizationEndpoint = "https://auth.com/authorization";
const tokenEndpoint = "https://auth.com/token";
const delegatedAuthConfig = {
issuer,
registrationEndpoint: issuer + "registration",
authorizationEndpoint: issuer + "auth",
tokenEndpoint,
};
const delegatedAuthConfig = makeDelegatedAuthConfig();
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
const clientId = "xyz789";
const baseUrl = "https://test.com";
// 14.03.2022 16:15
const now = 1647270879403;
beforeAll(() => {
jest.spyOn(logger, "warn");
jest.setSystemTime(now);
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
});
afterEach(() => {
@@ -97,25 +103,42 @@ describe("oidc authorization", () => {
"A secure context is required to generate code challenge. Using plain text code challenge",
);
});
});
describe("generateOidcAuthorizationUrl()", () => {
it("should generate url with correct parameters", async () => {
const nonce = "abc123";
const metadata = delegatedAuthConfig.metadata;
it("uses a s256 code challenge when crypto is available", async () => {
jest.spyOn(crypto.subtleCrypto, "digest");
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
const authUrl = new URL(
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
await generateOidcAuthorizationUrl({
metadata,
homeserverUrl: baseUrl,
clientId,
redirectUri: baseUrl,
nonce,
}),
);
const codeChallenge = authUrl.searchParams.get("code_challenge");
expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object));
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
expect(authUrl.searchParams.get("response_type")).toEqual("code");
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
// scope minus the 10char random device id at the end
expect(authUrl.searchParams.get("scope")!.slice(0, -10)).toEqual(
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:",
);
expect(authUrl.searchParams.get("state")).toBeTruthy();
expect(authUrl.searchParams.get("nonce")).toEqual(nonce);
// didn't use plain text code challenge
expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge);
expect(codeChallenge).toBeTruthy();
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
});
});
describe("completeAuthorizationCodeGrant", () => {
const codeVerifier = "abc123";
const homeserverUrl = "https://server.org/";
const identityServerUrl = "https://id.org/";
const nonce = "test-nonce";
const redirectUri = baseUrl;
const code = "auth_code_xyz";
@@ -124,9 +147,11 @@ describe("oidc authorization", () => {
access_token: "test_access_token",
refresh_token: "test_refresh_token",
id_token: "valid.id.token",
expires_in: 12345,
expires_in: 300,
};
const metadata = mockOpenIdConfiguration();
const validDecodedIdToken = {
// nonce matches
nonce,
@@ -135,131 +160,236 @@ describe("oidc authorization", () => {
// audience is this client
aud: clientId,
// issuer matches
iss: delegatedAuthConfig.issuer,
iss: metadata.issuer,
sub: "123",
};
const mockSessionStorage = (state: Record<string, unknown>): void => {
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation((key: unknown) => {
return state[key as string] ?? null;
});
jest.spyOn(sessionStorage.__proto__, "setItem").mockImplementation(
// @ts-ignore mock type
(key: string, value: unknown) => (state[key] = value),
);
jest.spyOn(sessionStorage.__proto__, "removeItem").mockImplementation((key: unknown) => {
const { [key as string]: value, ...newState } = state;
state = newState;
return value;
});
};
const getValueFromStorage = <T = string>(state: string, key: string): T => {
const storedState = window.sessionStorage.getItem(`mx_oidc_${state}`)!;
return JSON.parse(storedState)[key] as unknown as T;
};
/**
* These tests kind of integration test oidc auth, by using `generateOidcAuthorizationUrl` and mocked storage
* to mock the use case of initiating oidc auth, putting state in storage, redirecting to OP,
* then returning and using state to verfiy.
* Returns random state string used to access storage
* @param params
*/
const setupState = async (params = {}): Promise<string> => {
const url = await generateOidcAuthorizationUrl({
metadata,
redirectUri,
clientId,
homeserverUrl,
identityServerUrl,
nonce,
...params,
});
const state = new URL(url).searchParams.get("state")!;
// add the scope with correct deviceId to the mocked bearer token response
const scope = getValueFromStorage(state, "scope");
fetchMock.post(metadata.token_endpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
},
...validBearerTokenResponse,
scope,
});
return state;
};
beforeEach(() => {
fetchMock.mockClear();
fetchMock.resetBehavior();
fetchMock.post(tokenEndpoint, {
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
fetchMock.get(`${metadata.issuer}jwks`, {
status: 200,
body: JSON.stringify(validBearerTokenResponse),
headers: {
"Content-Type": "application/json",
},
keys: [],
});
mockSessionStorage({});
mocked(jwtDecode).mockReturnValue(validDecodedIdToken);
});
it("should make correct request to the token endpoint", async () => {
await completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
});
const state = await setupState();
const codeVerifier = getValueFromStorage(state, "code_verifier");
await completeAuthorizationCodeGrant(code, state);
expect(fetchMock).toHaveBeenCalledWith(tokenEndpoint, {
method: Method.Post,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`,
});
expect(fetchMock).toHaveBeenCalledWith(
metadata.token_endpoint,
expect.objectContaining({
method: Method.Post,
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
}),
);
// check body is correctly formed
const queryParams = fetchMock.mock.calls.find(([endpoint]) => endpoint === metadata.token_endpoint)![1]!
.body as URLSearchParams;
expect(queryParams.get("grant_type")).toEqual("authorization_code");
expect(queryParams.get("client_id")).toEqual(clientId);
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
expect(queryParams.get("code")).toEqual(code);
});
it("should return with valid bearer token", async () => {
const result = await completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
});
const state = await setupState();
const scope = getValueFromStorage(state, "scope");
const result = await completeAuthorizationCodeGrant(code, state);
expect(result).toEqual(validBearerTokenResponse);
expect(result).toEqual({
homeserverUrl,
identityServerUrl,
oidcClientSettings: {
clientId,
issuer: metadata.issuer,
},
tokenResponse: {
access_token: validBearerTokenResponse.access_token,
id_token: validBearerTokenResponse.id_token,
refresh_token: validBearerTokenResponse.refresh_token,
token_type: validBearerTokenResponse.token_type,
// this value is slightly unstable because it uses the clock
expires_at: result.tokenResponse.expires_at,
scope,
},
});
});
it("should return with valid bearer token where token_type is lowercase", async () => {
const state = await setupState();
const scope = getValueFromStorage(state, "scope");
const tokenResponse = {
...validBearerTokenResponse,
scope,
token_type: "bearer",
};
fetchMock.post(
tokenEndpoint,
{
status: 200,
body: JSON.stringify(tokenResponse),
headers: {
"Content-Type": "application/json",
},
...tokenResponse,
},
{ overwriteRoutes: true },
);
const result = await completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
const result = await completeAuthorizationCodeGrant(code, state);
expect(result).toEqual({
homeserverUrl,
identityServerUrl,
oidcClientSettings: {
clientId,
issuer: metadata.issuer,
},
// results in token that uses 'Bearer' token type
tokenResponse: {
access_token: validBearerTokenResponse.access_token,
id_token: validBearerTokenResponse.id_token,
refresh_token: validBearerTokenResponse.refresh_token,
token_type: "Bearer",
// this value is slightly unstable because it uses the clock
expires_at: result.tokenResponse.expires_at,
scope,
},
});
// results in token that uses 'Bearer' token type
expect(result).toEqual(validBearerTokenResponse);
expect(result.token_type).toEqual("Bearer");
expect(result.tokenResponse.token_type).toEqual("Bearer");
});
it("should throw with code exchange failed error when request fails", async () => {
it("should throw when state is not found in storage", async () => {
// don't setup sessionStorage with expected state
const state = "abc123";
fetchMock.post(
tokenEndpoint,
metadata.token_endpoint,
{
status: 500,
},
{ overwriteRoutes: true },
);
await expect(() =>
completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
}),
).rejects.toThrow(new Error(OidcError.CodeExchangeFailed));
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
new Error(OidcError.MissingOrInvalidStoredState),
);
});
it("should throw with code exchange failed error when request fails", async () => {
const state = await setupState();
fetchMock.post(
metadata.token_endpoint,
{
status: 500,
},
{ overwriteRoutes: true },
);
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
new Error(OidcError.CodeExchangeFailed),
);
});
it("should throw invalid token error when token is invalid", async () => {
const state = await setupState();
const invalidBearerTokenResponse = {
...validBearerTokenResponse,
access_token: null,
};
fetchMock.post(
tokenEndpoint,
{ status: 200, body: JSON.stringify(invalidBearerTokenResponse) },
metadata.token_endpoint,
{
headers: {
"Content-Type": "application/json",
},
...invalidBearerTokenResponse,
},
{ overwriteRoutes: true },
);
await expect(() =>
completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
}),
).rejects.toThrow(new Error(OidcError.InvalidBearerTokenResponse));
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
new Error(OidcError.InvalidBearerTokenResponse),
);
});
it("should throw invalid id token error when id_token is invalid", async () => {
const state = await setupState();
mocked(jwtDecode).mockReturnValue({
...validDecodedIdToken,
// invalid audience
aud: "something-else",
});
await expect(() =>
completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
}),
).rejects.toThrow(new Error(OidcError.InvalidIdToken));
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
new Error(OidcError.InvalidIdToken),
);
});
});
});
+2 -2
View File
@@ -66,7 +66,7 @@ describe("registerOidcClient()", () => {
fetchMockJest.post(registrationEndpoint, {
status: 500,
});
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
OidcError.DynamicRegistrationFailed,
);
});
@@ -77,7 +77,7 @@ describe("registerOidcClient()", () => {
// no clientId in response
body: "{}",
});
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
OidcError.DynamicRegistrationInvalid,
);
});
+16 -25
View File
@@ -35,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
},
};
it("should throw not supported error when wellKnown has no m.authentication section", () => {
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
});
it("should throw misconfigured error when authentication issuer is not a string", () => {
@@ -45,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
issuer: { url: "test.com" },
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is not a string", () => {
@@ -56,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
account: { url: "test" },
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is false", () => {
@@ -67,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
account: false,
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should return valid config when wk uses stable m.authentication", () => {
@@ -78,7 +84,7 @@ describe("validateWellKnownAuthentication()", () => {
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
account: "account.com",
});
@@ -91,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
issuer: "test.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
@@ -104,24 +110,10 @@ describe("validateWellKnownAuthentication()", () => {
somethingElse: "test",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
it("should return valid config when wk uses unstable prefix for m.authentication", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.unstable!]: {
issuer: "test.com",
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
issuer: "test.com",
account: "account.com",
});
});
});
describe("validateOIDCIssuerWellKnown", () => {
@@ -129,6 +121,7 @@ describe("validateOIDCIssuerWellKnown", () => {
authorization_endpoint: "https://test.org/authorize",
token_endpoint: "https://authorize.org/token",
registration_endpoint: "https://authorize.org/regsiter",
revocation_endpoint: "https://authorize.org/regsiter",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
@@ -155,10 +148,8 @@ describe("validateOIDCIssuerWellKnown", () => {
response_types_supported: [],
});
}).toThrow(OidcError.OpSupport);
expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid");
expect(logger.error).toHaveBeenCalledWith(
"OIDC issuer configuration: response_types_supported is invalid. code is required.",
);
expect(logger.error).toHaveBeenCalledWith("Missing or invalid property: authorization_endpoint");
expect(logger.error).toHaveBeenCalledWith("Invalid property: response_types_supported. code is required.");
});
it("should return validated issuer config", () => {
+1 -1
View File
@@ -657,7 +657,7 @@ describe("NotificationService", function () {
content: {
"body": "",
"msgtype": "m.text",
"org.matrix.msc3952.mentions": {},
"m.mentions": {},
},
});
+3 -3
View File
@@ -195,7 +195,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
it("retries on retryImmediately()", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
@@ -219,7 +219,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
it("retries on when client is started", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
@@ -243,7 +243,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
it("retries when a message is retried", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
+130 -32
View File
@@ -16,9 +16,9 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts";
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
import { MatrixClient } from "../../src/client";
import { EventType } from "../../src/matrix";
import { EventType, MatrixEvent, Room } from "../../src/matrix";
import { synthesizeReceipt } from "../../src/models/read-receipt";
import { encodeUri } from "../../src/utils";
import * as utils from "../test-utils/test-utils";
@@ -42,42 +42,46 @@ let httpBackend: MockHttpBackend;
const THREAD_ID = "$thread_event_id";
const ROOM_ID = "!123:matrix.org";
const threadEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a thread",
"m.relates_to": {
"event_id": THREAD_ID,
"m.in_reply_to": {
event_id: THREAD_ID,
},
"rel_type": "m.thread",
},
},
});
const roomEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
body: "Hello from a room",
},
});
describe("Read receipt", () => {
let threadEvent: MatrixEvent;
let roomEvent: MatrixEvent;
beforeEach(() => {
httpBackend = new MockHttpBackend();
client = new MatrixClient({
userId: "@user:server",
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
});
client.isGuest = () => false;
client.supportsThreads = () => true;
threadEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a thread",
"m.relates_to": {
"event_id": THREAD_ID,
"m.in_reply_to": {
event_id: THREAD_ID,
},
"rel_type": "m.thread",
},
},
});
roomEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
body: "Hello from a room",
},
});
});
describe("sendReceipt", () => {
@@ -143,13 +147,69 @@ describe("Read receipt", () => {
await httpBackend.flushAllExpected();
await flushPromises();
});
it("should send a main timeline read receipt for a reaction to a thread root", async () => {
roomEvent.event.event_id = THREAD_ID;
const reaction = utils.mkReaction(roomEvent, client, client.getSafeUserId(), ROOM_ID);
const thread = new Room(ROOM_ID, client, client.getSafeUserId()).createThread(
THREAD_ID,
roomEvent,
[threadEvent],
false,
);
threadEvent.setThread(thread);
reaction.setThread(thread);
httpBackend
.when(
"POST",
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: reaction.getId()!,
}),
)
.check((request) => {
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
})
.respond(200, {});
client.sendReceipt(reaction, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("should always send unthreaded receipts if threads support is disabled", async () => {
client.supportsThreads = () => false;
httpBackend
.when(
"POST",
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: roomEvent.getId()!,
}),
)
.check((request) => {
expect(request.data.thread_id).toEqual(undefined);
})
.respond(200, {});
client.sendReceipt(roomEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
});
describe("synthesizeReceipt", () => {
it.each([
{ event: roomEvent, destinationId: MAIN_ROOM_TIMELINE },
{ event: threadEvent, destinationId: threadEvent.threadRootId! },
])("adds the receipt to $destinationId", ({ event, destinationId }) => {
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
const event = getEvent();
const userId = "@bob:example.org";
const receiptType = ReceiptType.Read;
@@ -160,4 +220,42 @@ describe("Read receipt", () => {
expect(content.thread_id).toEqual(destinationId);
});
});
describe("addReceiptToStructure", () => {
it("should not allow an older unthreaded receipt to clobber a `main` threaded one", () => {
const userId = client.getSafeUserId();
const room = new Room(ROOM_ID, client, userId);
const unthreadedReceipt: WrappedReceipt = {
eventId: "$olderEvent",
data: {
ts: 1234567880,
},
};
const mainTimelineReceipt: WrappedReceipt = {
eventId: "$newerEvent",
data: {
ts: 1234567890,
},
};
room.addReceiptToStructure(
mainTimelineReceipt.eventId,
ReceiptType.ReadPrivate,
userId,
mainTimelineReceipt.data,
false,
);
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
room.addReceiptToStructure(
unthreadedReceipt.eventId,
ReceiptType.ReadPrivate,
userId,
unthreadedReceipt.data,
false,
);
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
});
});
});
+6 -7
View File
@@ -96,7 +96,7 @@ describe("ECDHv2", function () {
expect(aliceChecksum).toEqual(bobChecksum);
expect(alice.connect()).rejects.toThrow();
await expect(alice.connect()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
await bob.cancel(RendezvousFailureReason.Unknown);
@@ -120,9 +120,9 @@ describe("ECDHv2", function () {
alice.close();
expect(alice.connect()).rejects.toThrow();
expect(alice.send({})).rejects.toThrow();
expect(alice.receive()).rejects.toThrow();
await expect(alice.connect()).rejects.toThrow();
await expect(alice.send({})).rejects.toThrow();
await expect(alice.receive()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
await bob.cancel(RendezvousFailureReason.Unknown);
@@ -146,10 +146,10 @@ describe("ECDHv2", function () {
// send a message without encryption
await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" });
expect(bob.receive()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
await bob.cancel(RendezvousFailureReason.Unknown);
await expect(bob.receive()).rejects.toThrow();
});
it("ciphertext before set up", async function () {
@@ -164,9 +164,8 @@ describe("ECDHv2", function () {
await bobTransport.send({ iv: "dummy", ciphertext: "dummy" });
expect(alice.receive()).rejects.toThrow();
await alice.cancel(RendezvousFailureReason.Unknown);
await expect(alice.receive()).rejects.toThrow();
});
});
});
+4 -4
View File
@@ -172,7 +172,7 @@ describe("Rendezvous", function () {
const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined);
await httpBackend.flush("");
expect(cancelPromise).resolves.toBeUndefined();
await expect(cancelPromise).resolves.toBeUndefined();
httpBackend.verifyNoOutstandingExpectation();
httpBackend.verifyNoOutstandingRequests();
@@ -603,7 +603,7 @@ describe("Rendezvous", function () {
it("device not online within timeout", async function () {
const { aliceRz } = await completeLogin({});
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("device appears online within timeout", async function () {
@@ -627,7 +627,7 @@ describe("Rendezvous", function () {
getFingerprint: () => "bbbb",
};
}, 1500);
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("mismatched device key", async function () {
@@ -636,6 +636,6 @@ describe("Rendezvous", function () {
getFingerprint: () => "XXXX",
},
});
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
});
});
@@ -95,10 +95,10 @@ describe("SimpleHttpRendezvousTransport", function () {
httpBackend.verifyNoOutstandingExpectation();
}
}
it("should throw an error when no server available", function () {
it("should throw an error when no server available", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn });
expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
await expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
});
it("POST to fallback server", async function () {
@@ -130,7 +130,6 @@ describe("SimpleHttpRendezvousTransport", function () {
fetchFn,
});
const prom = simpleHttpTransport.send({});
expect(prom).rejects.toThrow();
httpBackend.when("POST", "https://fallbackserver/rz").response = {
body: null,
response: {
@@ -138,7 +137,7 @@ describe("SimpleHttpRendezvousTransport", function () {
headers: {},
},
};
await httpBackend.flush("");
await Promise.all([expect(prom).rejects.toThrow(), httpBackend.flush("")]);
});
it("POST with absolute path response", async function () {
@@ -364,7 +363,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fallbackRzServer: "https://fallbackserver/rz",
fetchFn,
});
expect(simpleHttpTransport.details()).rejects.toThrow();
await expect(simpleHttpTransport.details()).rejects.toThrow();
});
it("send after cancelled", async function () {
@@ -375,7 +374,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fetchFn,
});
await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined);
expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
await expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
});
it("receive before ready", async function () {
@@ -385,7 +384,7 @@ describe("SimpleHttpRendezvousTransport", function () {
fallbackRzServer: "https://fallbackserver/rz",
fetchFn,
});
expect(simpleHttpTransport.receive()).rejects.toThrow();
await expect(simpleHttpTransport.receive()).rejects.toThrow();
});
it("404 failure callback", async function () {
@@ -398,7 +397,6 @@ describe("SimpleHttpRendezvousTransport", function () {
onFailure,
});
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined();
httpBackend.when("POST", "https://fallbackserver/rz").response = {
body: null,
response: {
@@ -406,7 +404,10 @@ describe("SimpleHttpRendezvousTransport", function () {
headers: {},
},
};
await httpBackend.flush("", 1);
await Promise.all([
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined(),
httpBackend.flush("", 1),
]);
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown);
});
@@ -438,7 +439,6 @@ describe("SimpleHttpRendezvousTransport", function () {
}
{
// GET with 404 to simulate expiry
expect(simpleHttpTransport.receive()).resolves.toBeUndefined();
httpBackend.when("GET", "https://fallbackserver/rz/123").response = {
body: { foo: "baa" },
response: {
@@ -446,7 +446,7 @@ describe("SimpleHttpRendezvousTransport", function () {
headers: {},
},
};
await httpBackend.flush("");
await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]);
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired);
}
});
+76
View File
@@ -0,0 +1,76 @@
/*
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 { EventType, MatrixClient, Room } from "../../src";
import { RoomHierarchy } from "../../src/room-hierarchy";
describe("RoomHierarchy", () => {
const roomId = "!room:server";
const client = new MatrixClient({ baseUrl: "https://server", userId: "@user:server" });
it("should load data from /hierarchy API", async () => {
const spy = fetchMock.getOnce(
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
{
rooms: [],
},
{ overwriteRoutes: true },
);
const room = new Room(roomId, client, client.getSafeUserId());
const hierarchy = new RoomHierarchy(room);
const res = await hierarchy.load();
expect(spy).toHaveBeenCalled();
expect(res).toHaveLength(0);
});
describe("itSuggested", () => {
it("should return true if a room is suggested", async () => {
const spy = fetchMock.getOnce(
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
{
rooms: [
{
children_state: [
{
origin_server_ts: 111,
content: {
suggested: true,
via: ["matrix.org"],
},
type: EventType.SpaceChild,
state_key: "!child_room:server",
},
],
room_id: roomId,
},
],
},
{ overwriteRoutes: true },
);
const room = new Room(roomId, client, client.getSafeUserId());
const hierarchy = new RoomHierarchy(room);
await hierarchy.load();
expect(spy).toHaveBeenCalled();
expect(hierarchy.isSuggested(hierarchy.root.roomId, "!child_room:server")).toBeTruthy();
});
});
});
+85 -34
View File
@@ -2556,7 +2556,7 @@ describe("Room", function () {
next_batch: "start_token",
});
let prom = emitPromise(room, ThreadEvent.New);
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([randomMessage, threadRoot, threadResponse]);
const thread: Thread = await prom;
await emitPromise(room, ThreadEvent.Update);
@@ -2583,9 +2583,11 @@ describe("Room", function () {
},
});
prom = emitPromise(room, ThreadEvent.Update);
await room.addLiveEvents([threadResponseEdit]);
await prom;
// XXX: If we add the relation to the thread response before the thread finishes fetching via /relations
// then the test will fail
await emitPromise(room, ThreadEvent.Update);
await emitPromise(room, ThreadEvent.Update);
await Promise.all([emitPromise(room, ThreadEvent.Update), room.addLiveEvents([threadResponseEdit])]);
expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
});
@@ -2765,7 +2767,7 @@ describe("Room", function () {
"m.relations": {
"m.thread": {
latest_event: threadResponse2.event,
count: 2,
count: 1,
current_user_participated: true,
},
},
@@ -2787,10 +2789,10 @@ describe("Room", function () {
let prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1]);
const thread: Thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread.initialEventsFetched).toBeTruthy();
await room.addLiveEvents([threadResponse2]);
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId());
@@ -2809,11 +2811,10 @@ describe("Room", function () {
},
});
prom = emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2);
await room.addLiveEvents([threadResponse2Redaction]);
await prom;
await emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2);
await emitPromise(room, ThreadEvent.Update);
await room.addLiveEvents([threadResponse2Redaction]);
expect(thread).toHaveLength(1);
expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId());
@@ -2849,7 +2850,7 @@ describe("Room", function () {
Thread.setServerSideSupport(FeatureSupport.Stable);
const room = new Room(roomId, client, userA);
it("thread root and its relations&redactions should be in both", () => {
it("thread root and its relations&redactions should be in main timeline", () => {
const randomMessage = mkMessage();
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
@@ -2867,6 +2868,9 @@ describe("Room", function () {
threadReaction2Redaction,
];
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
events.slice(1).forEach((ev) => ev.setThread(thread));
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInThread).toBeFalsy();
@@ -2878,14 +2882,11 @@ describe("Room", function () {
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeFalsy();
});
it("thread response and its relations&redactions should be only in thread timeline", () => {
@@ -2909,25 +2910,39 @@ describe("Room", function () {
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId());
});
it("reply to thread response and its relations&redactions should be only in main timeline", () => {
it("reply to thread response and its relations&redactions should be only in thread timeline", () => {
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const reply1 = mkReply(threadResponse1);
const reaction1 = utils.mkReaction(reply1, room.client, userA, roomId);
const reaction2 = utils.mkReaction(reply1, room.client, userA, roomId);
const reaction2Redaction = mkRedaction(reply1);
const threadResp1 = mkThreadResponse(threadRoot);
const threadResp1Reply1 = mkReply(threadResp1);
const threadResp1Reply1Reaction1 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
const threadResp1Reply1Reaction2 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
const thResp1Rep1React2Redaction = mkRedaction(threadResp1Reply1);
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, reply1, reaction1, reaction2, reaction2Redaction];
const events = [
threadRoot,
threadResp1,
threadResp1Reply1,
threadResp1Reply1Reaction1,
threadResp1Reply1Reaction2,
thResp1Rep1React2Redaction,
];
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(reaction1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reaction1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(reaction2, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reaction2, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(reaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reaction2Redaction, events, roots).shouldLiveInThread).toBeFalsy();
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
events.forEach((ev) => ev.setThread(thread));
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).threadId).toBe(thread.id);
});
it("reply to reply to thread root should only be in the main timeline", () => {
@@ -2939,12 +2954,40 @@ describe("Room", function () {
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, reply1, reply2];
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
threadResponse1.setThread(thread);
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy();
});
it("edit to thread root should live in main timeline only", () => {
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const threadRootEdit = mkEdit(threadRoot);
threadRoot.makeReplaced(threadRootEdit);
const thread = room.createThread(threadRoot.getId()!, threadRoot, [threadResponse1], false);
threadResponse1.setThread(thread);
threadRootEdit.setThread(thread);
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, threadRootEdit];
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInThread).toBeFalsy();
});
it("should aggregate relations in thread event timeline set", async () => {
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
@@ -2976,6 +3019,14 @@ describe("Room", function () {
expect(responseRelations![0][1].size).toEqual(1);
expect(responseRelations![0][1].has(threadReaction)).toBeTruthy();
});
it("a non-thread reply to an unknown parent event should live in the main timeline only", async () => {
const message = mkMessage(); // we do not add this message to any timelines
const reply = mkReply(message);
expect(room.eventShouldLiveIn(reply).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply).shouldLiveInThread).toBeFalsy();
});
});
describe("getEventReadUpTo()", () => {
@@ -3062,10 +3113,10 @@ describe("Room", function () {
it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as WrappedReceipt;
return { eventId: "eventId1", data: { ts: 123 } };
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as WrappedReceipt;
return { eventId: "eventId2", data: { ts: 123 } };
}
return null;
};
@@ -15,10 +15,11 @@ limitations under the License.
*/
import { Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
describe("CrossSigningIdentity", () => {
describe("bootstrapCrossSigning", () => {
@@ -31,6 +32,9 @@ describe("CrossSigningIdentity", () => {
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
/** A mock ServerSideSecretStorage which crossSigning is connected to */
let secretStorage: Mocked<ServerSideSecretStorage>;
beforeEach(async () => {
await RustSdkCryptoJs.initAsync();
@@ -44,7 +48,11 @@ describe("CrossSigningIdentity", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
secretStorage = {
get: jest.fn(),
} as unknown as Mocked<ServerSideSecretStorage>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage, jest.fn());
});
it("should do nothing if keys are present on-device and in secret storage", async () => {
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
import { Mocked } from "jest-mock";
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
@@ -16,7 +16,7 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import { Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import {
KeysBackupRequest,
KeysClaimRequest,
@@ -26,7 +26,7 @@ import {
SignatureUploadRequest,
SigningKeysUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
@@ -161,7 +161,7 @@ describe("OutgoingRequestProcessor", () => {
.when("PUT", "/_matrix")
.check((req) => {
expect(req.path).toEqual(
"https://example.com/_matrix/client/v3/room/test%2Froom/send/test%2Ftype/test%2Ftxnid",
"https://example.com/_matrix/client/v3/rooms/test%2Froom/send/test%2Ftype/test%2Ftxnid",
);
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
+128 -79
View File
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import { Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
@@ -23,6 +23,8 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import {
CryptoEvent,
Device,
DeviceVerification,
HttpApiEvent,
HttpApiEventHandlerMap,
IHttpOpts,
@@ -34,7 +36,7 @@ import {
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
import { CryptoCallbacks, ImportRoomKeysOpts, VerificationRequest } from "../../../src/crypto-api";
import * as testData from "../../test-utils/test-data";
@@ -42,6 +44,10 @@ import * as testData from "../../test-utils/test-data";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
afterEach(() => {
fetchMock.reset();
});
describe("RustCrypto", () => {
describe(".importRoomKeys and .exportRoomKeys", () => {
let rustCrypto: RustCrypto;
@@ -55,32 +61,7 @@ describe("RustCrypto", () => {
);
it("should import and export keys", async () => {
const someRoomKeys = [
{
algorithm: "m.megolm.v1.aes-sha2",
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
session_id: "iGQG5GaP1/B3dSH6zCQDQqrNuotrtQjVC7w1OsUDwbg",
session_key:
"AQAAAADaCbP2gdOy8jrhikjploKgSBaFSJ5rvHcziaADbwNEzeCSrfuAUlXvCvxik8kU+MfCHIi5arN2M7UM5rGKdzkHnkReoIByFkeMdbjKWk5SFpVQexcM74eDhBGj+ICkQqOgApfnEbSswrmreB0+MhHHyLStwW5fy5f8A9QW1sbPuohkBuRmj9fwd3Uh+swkA0KqzbqLa7UI1Qu8NTrFA8G4",
sender_claimed_keys: {
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
},
forwarding_curve25519_key_chain: [],
},
{
algorithm: "m.megolm.v1.aes-sha2",
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
session_id: "P/Jy9Tog4CMtLseeS4Fe2AEXZov3k6cibcop/uyhr78",
session_key:
"AQAAAAATyAVm0c9c9DW9Od72MxvfSDYoysBw3C6yMJ3bYuTmssHN7yNGm59KCtKeFp2Y5qO7lvUmwOfSTvTASUb7HViE7Lt+Bvp5WiMTJ2Pv6m+N12ihyowV5lgtKFWI18Wxd0AugMTVQRwjBK6aMobf86NXWD2hiKm3N6kWbC0PXmqV7T/ycvU6IOAjLS7HnkuBXtgBF2aL95OnIm3KKf7soa+/",
sender_claimed_keys: {
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
},
forwarding_curve25519_key_chain: [],
},
];
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
let importTotal = 0;
const opt: ImportRoomKeysOpts = {
progressCallback: (stage) => {
@@ -89,11 +70,11 @@ describe("RustCrypto", () => {
};
await rustCrypto.importRoomKeys(someRoomKeys, opt);
expect(importTotal).toBe(2);
expect(importTotal).toBe(someRoomKeys.length);
const keys = await rustCrypto.exportRoomKeys();
expect(Array.isArray(keys)).toBeTruthy();
expect(keys.length).toBe(2);
expect(keys.length).toBe(someRoomKeys.length);
const aSession = someRoomKeys[0];
@@ -164,7 +145,7 @@ describe("RustCrypto", () => {
});
});
it("getCrossSigningKeyId", async () => {
it("getCrossSigningKeyId when there is no cross signing keys", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
});
@@ -347,6 +328,60 @@ describe("RustCrypto", () => {
});
});
describe("setDeviceVerified", () => {
let rustCrypto: RustCrypto;
async function getTestDevice(): Promise<Device> {
const devices = await rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
return devices.get(testData.TEST_USER_ID)!.get(testData.TEST_DEVICE_ID)!;
}
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
}),
testData.TEST_USER_ID,
);
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
[testData.TEST_USER_ID]: {
[testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA,
},
},
});
// call onSyncCompleted to kick off the outgoingRequestLoop and download the device list.
rustCrypto.onSyncCompleted({});
// before the call, the device should be unverified.
const device = await getTestDevice();
expect(device.verified).toEqual(DeviceVerification.Unverified);
});
it("should throw an error for an unknown device", async () => {
await expect(rustCrypto.setDeviceVerified(testData.TEST_USER_ID, "xxy")).rejects.toThrow("Unknown device");
});
it("should mark an unverified device as verified", async () => {
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// and confirm that the device is now verified
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Verified);
});
it("should mark a verified device as unverified", async () => {
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Verified);
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID, false);
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Unverified);
});
});
describe("getDeviceVerificationStatus", () => {
let rustCrypto: RustCrypto;
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
@@ -390,60 +425,39 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID);
});
afterEach(() => {
jest.useRealTimers();
});
it("returns false initially", async () => {
jest.useFakeTimers();
const prom = rustCrypto.userHasCrossSigningKeys();
// the getIdentity() request should wait for a /keys/query request to complete, but times out after 1500ms
await jest.advanceTimersByTimeAsync(2000);
await expect(prom).resolves.toBe(false);
});
it("returns false if there is no cross-signing identity", async () => {
// @ts-ignore private field
const olmMachine = rustCrypto.olmMachine;
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
// pick out the KeysQueryRequest, and respond to it with the device keys but *no* cross-signing keys.
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
await olmMachine.markRequestAsSent(
req.id!,
req.type,
JSON.stringify({
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
}),
testData.TEST_USER_ID,
);
});
it("throws an error if the fetch fails", async () => {
fetchMock.post("path:/_matrix/client/v3/keys/query", 400);
await expect(rustCrypto.userHasCrossSigningKeys()).rejects.toThrow("400 error");
});
it("returns false if the user has no cross-signing keys", async () => {
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
});
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(false);
});
it("returns true if OlmMachine has a cross-signing identity", async () => {
// @ts-ignore private field
const olmMachine = rustCrypto.olmMachine;
it("returns true if the user has cross-signing keys", async () => {
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
...testData.SIGNED_CROSS_SIGNING_KEYS_DATA,
});
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
// pick out the KeysQueryRequest, and respond to it with the cross-signing keys
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
await olmMachine.markRequestAsSent(
req.id!,
req.type,
JSON.stringify({
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
...testData.SIGNED_CROSS_SIGNING_KEYS_DATA,
}),
);
// ... and we should now have cross-signing keys.
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(true);
});
});
@@ -520,6 +534,41 @@ describe("RustCrypto", () => {
);
});
});
describe("get|storeSessionBackupPrivateKey", () => {
it("can save and restore a key", async () => {
const key = "testtesttesttesttesttesttesttest";
const rustCrypto = await makeTestRustCrypto();
await rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key));
const fetched = await rustCrypto.getSessionBackupPrivateKey();
expect(new TextDecoder().decode(fetched!)).toEqual(key);
});
});
describe("getActiveSessionBackupVersion", () => {
it("returns null", async () => {
const rustCrypto = await makeTestRustCrypto();
expect(await rustCrypto.getActiveSessionBackupVersion()).toBeNull();
});
});
describe("findVerificationRequestDMInProgress", () => {
it("throws an error if the userId is not provided", async () => {
const rustCrypto = await makeTestRustCrypto();
expect(() => rustCrypto.findVerificationRequestDMInProgress(testData.TEST_ROOM_ID)).toThrow(
"missing userId",
);
});
});
describe("requestVerificationDM", () => {
it("send verification request to an unknown user", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(() =>
rustCrypto.requestVerificationDM("@bob:example.com", testData.TEST_ROOM_ID),
).rejects.toThrow("unknown userId @bob:example.com");
});
});
});
/** build a basic RustCrypto instance for testing
+38 -3
View File
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { Mocked } from "jest-mock";
import { RustVerificationRequest } from "../../../src/rust-crypto/verification";
import { isVerificationEvent, RustVerificationRequest } from "../../../src/rust-crypto/verification";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { EventType, MatrixEvent, MsgType } from "../../../src";
describe("VerificationRequest", () => {
describe("pending", () => {
@@ -80,6 +81,40 @@ describe("VerificationRequest", () => {
});
});
describe("isVerificationEvent", () => {
it.each([
[EventType.KeyVerificationCancel],
[EventType.KeyVerificationDone],
[EventType.KeyVerificationMac],
[EventType.KeyVerificationStart],
[EventType.KeyVerificationKey],
[EventType.KeyVerificationReady],
[EventType.KeyVerificationAccept],
])("should return true with %s event", (eventType) => {
const event = new MatrixEvent({
type: eventType,
});
expect(isVerificationEvent(event)).toBe(true);
});
it("should return true with EventType.RoomMessage and MsgType.KeyVerificationRequest", () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
content: {
msgtype: MsgType.KeyVerificationRequest,
},
});
expect(isVerificationEvent(event)).toBe(true);
});
it("should return false with a non verification event", () => {
const event = new MatrixEvent({
type: EventType.RoomName,
});
expect(isVerificationEvent(event)).toBe(false);
});
});
/** build a RustVerificationRequest with default parameters */
function makeTestRequest(
inner?: RustSdkCryptoJs.VerificationRequest,
@@ -87,7 +122,7 @@ function makeTestRequest(
): RustVerificationRequest {
inner ??= makeMockedInner();
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
return new RustVerificationRequest(inner, outgoingRequestProcessor, undefined);
return new RustVerificationRequest(inner, outgoingRequestProcessor, []);
}
/** Mock up a rust-side VerificationRequest */
+1 -1
View File
@@ -278,7 +278,7 @@ describe("IndexedDBStore", () => {
workerFactory: () => worker,
});
await store.startup();
await expect(store.destroy()).resolves;
await store.destroy();
expect(terminate).toHaveBeenCalled();
});
});
-2
View File
@@ -38,8 +38,6 @@ import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
import { ReceiptType } from "../../src/@types/read_receipts";
// TODO: Fix types throughout
describe("utils", function () {
describe("encodeParams", function () {
it("should url encode and concat with &s", function () {
+1 -1
View File
@@ -1607,7 +1607,7 @@ describe("Call", function () {
it("throws when there is no error listener", async () => {
call.off(CallEvent.Error, errorListener);
expect(call.placeVoiceCall()).rejects.toThrow();
await expect(call.placeVoiceCall()).rejects.toThrow();
});
describe("hasPeerConnection()", () => {
+2 -2
View File
@@ -137,8 +137,8 @@ export enum PushRuleKind {
export enum RuleId {
Master = ".m.rule.master",
IsUserMention = ".org.matrix.msc3952.is_user_mention",
IsRoomMention = ".org.matrix.msc3952.is_room_mention",
IsUserMention = ".m.rule.is_user_mention",
IsRoomMention = ".m.rule.is_room_mention",
ContainsDisplayName = ".m.rule.contains_display_name",
ContainsUserName = ".m.rule.contains_user_name",
AtRoomNotification = ".m.rule.roomnotif",
+16
View File
@@ -96,4 +96,20 @@ declare global {
// but we still need this for MatrixCall::getRidOfRTXCodecs()
setCodecPreferences(codecs: RTCRtpCodecCapability[]): void;
}
interface RequestInit {
/**
* Specifies the priority of the fetch request relative to other requests of the same type.
* Must be one of the following strings:
* high: A high priority fetch request relative to other requests of the same type.
* low: A low priority fetch request relative to other requests of the same type.
* auto: Automatically determine the priority of the fetch request relative to other requests of the same type (default).
*
* @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fetch-priority-attribute
* @see https://github.com/microsoft/TypeScript/issues/54472
* @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#browser_compatibility
* Not yet supported in Safari or Firefox
*/
priority?: "high" | "low" | "auto";
}
}
+12
View File
@@ -45,6 +45,18 @@ export interface IJoinRoomOpts {
viaServers?: string[];
}
export interface KnockRoomOpts {
/**
* The reason for the knock.
*/
reason?: string;
/**
* The server names to try and knock through in addition to those that are automatically chosen.
*/
viaServers?: string | string[];
}
export interface IRedactOpts {
reason?: string;
/**
+89 -5
View File
@@ -15,11 +15,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { SigningKey } from "oidc-client-ts";
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api";
import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate";
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
import {
ValidatedIssuerConfig,
ValidatedIssuerMetadata,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
import { OidcError } from "./oidc/error";
import { MINIMUM_MATRIX_VERSION } from "./version-support";
// Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -42,6 +51,9 @@ enum AutoDiscoveryError {
InvalidIs = "Invalid identity server discovery response",
MissingWellknown = "No .well-known JSON file found",
InvalidJson = "Invalid JSON",
HomeserverTooOld = "The homeserver does not meet the minimum version requirements",
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
}
interface AutoDiscoveryState {
@@ -50,12 +62,26 @@ interface AutoDiscoveryState {
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
/**
* @deprecated in favour of OidcClientConfig
*/
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
/**
* @experimental
*/
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
"m.authentication"?: DelegatedAuthConfig | AutoDiscoveryState;
/**
* @experimental
*/
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
}
/**
@@ -86,6 +112,8 @@ export class AutoDiscovery {
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
/**
@@ -177,7 +205,7 @@ export class AutoDiscovery {
// Step 3: Make sure the homeserver URL points to a homeserver.
const hsVersions = await this.fetchWellKnownObject<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions?.raw?.["versions"]) {
if (!hsVersions || !Array.isArray(hsVersions.raw?.["versions"])) {
logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
@@ -188,6 +216,18 @@ export class AutoDiscovery {
return Promise.resolve(clientConfig);
}
// Step 3.1: Non-spec check to ensure the server will actually work for us
if (!hsVersions.raw!["versions"].includes(MINIMUM_MATRIX_VERSION)) {
logger.error("Homeserver does not meet minimum version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_HOMESERVER_TOO_OLD;
// Supply the base_url to the caller because they may be ignoring liveliness
// errors, like this one.
clientConfig["m.homeserver"].base_url = hsUrl;
return Promise.resolve(clientConfig);
}
// Step 4: Now that the homeserver looks valid, update our client config.
clientConfig["m.homeserver"] = {
state: AutoDiscovery.SUCCESS,
@@ -262,7 +302,7 @@ export class AutoDiscovery {
}
});
const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown);
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally)
@@ -271,6 +311,7 @@ export class AutoDiscovery {
/**
* Validate delegated auth configuration
* @deprecated use discoverAndValidateAuthenticationConfig
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
@@ -284,7 +325,8 @@ export class AutoDiscovery {
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
@@ -319,6 +361,48 @@ export class AutoDiscovery {
}
}
/**
* Validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, validated authentication metadata and optionally signing keys will be returned
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async discoverAndValidateAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const result = await discoverAndValidateAuthenticationConfig(authentication);
// include this for backwards compatibility
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
const response = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...validatedIssuerConfig,
...result,
};
return response;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL
+194 -271
View File
@@ -21,7 +21,7 @@ limitations under the License.
import { Optional } from "matrix-events-sdk";
import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto";
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
import { ISyncStateData, SetPresence, SyncApi, SyncApiOptions, SyncState } from "./sync";
import {
EventStatus,
IContent,
@@ -71,6 +71,7 @@ import {
UploadResponse,
HTTPError,
IRequestOpts,
Body,
} from "./http-api";
import {
Crypto,
@@ -111,7 +112,7 @@ import * as ContentHelpers from "./content-helpers";
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
import { IPowerLevelsContent, RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
import {
IAddThreePidOnlyBody,
IBindThreePidBody,
@@ -132,6 +133,7 @@ import {
IFilterResponse,
ITagsResponse,
IStatusResponse,
KnockRoomOpts,
} from "./@types/requests";
import {
EventType,
@@ -208,7 +210,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
import { BootstrapCrossSigningOpts, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
import { DeviceInfoMap } from "./crypto/DeviceList";
import {
AddSecretStorageKeyOpts,
@@ -522,13 +524,8 @@ export interface Capabilities {
[UNSTABLE_MSC3882_CAPABILITY.altName]?: IMSC3882GetLoginTokenCapability;
}
/* eslint-disable camelcase */
export interface ICrossSigningKey {
keys: { [algorithm: string]: string };
signatures?: ISignatures;
usage: string[];
user_id: string;
}
/** @deprecated prefer {@link CrossSigningKeyInfo}. */
export type ICrossSigningKey = CrossSigningKeyInfo;
enum CrossSigningKeyType {
MasterKey = "master_key",
@@ -1289,7 +1286,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
baseUrl: opts.baseUrl,
idBaseUrl: opts.idBaseUrl,
accessToken: opts.accessToken,
prefix: ClientPrefix.R0,
prefix: ClientPrefix.V3,
onlyData: true,
extraParams: opts.queryParams,
localTimeoutMs: opts.localTimeoutMs,
@@ -2012,12 +2009,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Set whether this client is a guest account. <b>This method is experimental
* and may change without warning.</b>
* @param guest - True if this is a guest account.
* @experimental if the token is a macaroon, it should be encoded in it that it is a 'guest'
* access token, which means that the SDK can determine this entirely without
* the dev manually flipping this flag.
*/
public setGuest(guest: boolean): void {
// EXPERIMENTAL:
// If the token is a macaroon, it should be encoded in it that it is a 'guest'
// access token, which means that the SDK can determine this entirely without
// the dev manually flipping this flag.
this.isGuestAccount = guest;
}
@@ -2241,15 +2237,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.cryptoCallbacks,
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
);
rustCrypto.supportedVerificationMethods = this.verificationMethods;
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
this.cryptoBackend = rustCrypto;
// attach the event listeners needed by RustCrypto
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
this.on(ClientEvent.Event, (event) => {
rustCrypto.onLiveEventFromSync(event);
});
// re-emit the events emitted by the crypto impl
this.reEmitter.reEmit(rustCrypto, [CryptoEvent.VerificationRequestReceived]);
this.reEmitter.reEmit(rustCrypto, [
CryptoEvent.VerificationRequestReceived,
CryptoEvent.UserTrustStatusChanged,
CryptoEvent.KeyBackupStatus,
CryptoEvent.KeyBackupSessionsRemaining,
CryptoEvent.KeyBackupFailed,
]);
}
/**
@@ -2439,6 +2444,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns resolves to a VerificationRequest
* when the request has been sent to the other party.
*
* @deprecated Prefer {@link CryptoApi.requestVerificationDM}.
*/
public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
if (!this.crypto) {
@@ -2678,12 +2685,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Check the copy of our cross-signing key that we have in the device list and
* see if we can get the private key. If so, mark it as trusted.
* @param opts - ICheckOwnCrossSigningTrustOpts object
*
* @deprecated Unneeded for the new crypto
*/
public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise<void> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.checkOwnCrossSigningTrust(opts);
return this.cryptoBackend.checkOwnCrossSigningTrust(opts);
}
/**
@@ -2854,12 +2863,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Object with public key metadata, encoded private
* recovery key which should be disposed of after displaying to the user,
* and raw private key to avoid round tripping if needed.
*
* @deprecated Prefer {@link CryptoApi.createRecoveryKeyFromPassphrase | `CryptoApi.createRecoveryKeyFromPassphrase`}.
*/
public createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.createRecoveryKeyFromPassphrase(password);
return this.cryptoBackend.createRecoveryKeyFromPassphrase(password);
}
/**
@@ -2874,7 +2885,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* return true.
*
* @returns True if secret storage is ready to be used on this device
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}.
*/
public isSecretStorageReady(): Promise<boolean> {
if (!this.cryptoBackend) {
@@ -2896,13 +2907,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* - migrates Secure Secret Storage to use the latest algorithm, if an outdated
* algorithm is found
*
* @deprecated Use {@link CryptoApi#bootstrapSecretStorage}.
* @deprecated Use {@link CryptoApi.bootstrapSecretStorage | `CryptoApi.bootstrapSecretStorage`}.
*/
public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.bootstrapSecretStorage(opts);
return this.cryptoBackend.bootstrapSecretStorage(opts);
}
/**
@@ -3244,6 +3255,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*
* @deprecated Prefer {@link CryptoApi.checkKeyBackupAndEnable}.
*/
public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
if (!this.crypto) {
@@ -3279,6 +3292,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @param info - key backup info dict from getKeyBackupVersion()
*
* @deprecated Prefer {@link CryptoApi.isKeyBackupTrusted | `CryptoApi.isKeyBackupTrusted`}.
*/
public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> {
if (!this.crypto) {
@@ -3291,6 +3306,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns true if the client is configured to back up keys to
* the server, otherwise false. If we haven't completed a successful check
* of key backup status yet, returns null.
*
* @deprecated Prefer direct access to {@link CryptoApi.getActiveSessionBackupVersion}:
*
* ```javascript
* let enabled = (await client.getCrypto().getActiveSessionBackupVersion()) !== null;
* ```
*/
public getKeyBackupEnabled(): boolean | null {
if (!this.crypto) {
@@ -3305,6 +3326,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param info - Backup information object as returned by getKeyBackupVersion
* @returns Promise which resolves when complete.
*
* @deprecated Do not call this directly. Instead call {@link CryptoApi.checkKeyBackupAndEnable}.
*/
public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
if (!this.crypto) {
@@ -3410,9 +3433,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
await this.crypto.crossSigningInfo.signObject(data.auth_data, "master");
}
const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data, {
prefix: ClientPrefix.V3,
});
const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data);
// We could assume everything's okay and enable directly, but this ensures
// we run the same signature verification that will be used for future
@@ -3433,6 +3454,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// If we're currently backing up to this backup... stop.
// (We start using it automatically in createKeyBackupVersion
// so this is symmetrical).
// TODO: convert this to use crypto.getActiveSessionBackupVersion. And actually check the version.
if (this.crypto.backupManager.version) {
this.crypto.backupManager.disableKeyBackup();
}
@@ -3718,10 +3740,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
backupInfo: IKeyBackupInfo,
opts?: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
const privKey = await this.crypto.getSessionBackupPrivateKey();
const privKey = await this.cryptoBackend.getSessionBackupPrivateKey();
if (!privKey) {
throw new Error("Couldn't get key");
}
@@ -3759,7 +3781,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const cacheCompleteCallback = opts?.cacheCompleteCallback;
const progressCallback = opts?.progressCallback;
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
@@ -3782,9 +3804,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
}
if (!(privKey instanceof Uint8Array)) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
throw new Error(`restoreKeyBackup expects Uint8Array, got ${privKey}`);
}
// Cache the key, if possible.
// This is async.
this.crypto
this.cryptoBackend
.storeSessionBackupPrivateKey(privKey)
.catch((e) => {
logger.warn("Error caching session backup key:", e);
@@ -3841,7 +3867,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
algorithm.free();
}
await this.getCrypto()?.importRoomKeys(keys, {
await this.cryptoBackend.importRoomKeys(keys, {
progressCallback,
untrusted,
source: "backup",
@@ -3904,7 +3930,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public getMediaConfig(): Promise<IMediaConfig> {
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
prefix: MediaPrefix.R0,
prefix: MediaPrefix.V3,
});
}
@@ -4139,6 +4165,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return syncRoom;
}
/**
* Knock a room. If you have already knocked the room, this will no-op.
* @param roomIdOrAlias - The room ID or room alias to knock.
* @param opts - Options when knocking the room.
* @returns Promise which resolves: `{room_id: {string}}`
* @returns Rejects: with an error response.
*/
public knockRoom(roomIdOrAlias: string, opts: KnockRoomOpts = {}): Promise<{ room_id: string }> {
const room = this.getRoom(roomIdOrAlias);
if (room?.hasMembershipState(this.credentials.userId!, "knock")) {
return Promise.resolve({ room_id: room.roomId });
}
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
const queryParams: Record<string, string | string[]> = {};
if (opts.viaServers) {
queryParams.server_name = opts.viaServers;
}
const body: Record<string, string> = {};
if (opts.reason) {
body.reason = opts.reason;
}
return this.http.authedRequest(Method.Post, path, queryParams, body);
}
/**
* Resend an event. Will also retry any to-device messages waiting to be sent.
* @param event - The event to resend.
@@ -4256,24 +4310,48 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Set a power level to one or multiple users.
* Will apply changes atop of current power level event from local state if running & synced, falling back
* to fetching latest from the `/state/` API.
* @param roomId - the room to update power levels in
* @param userId - the ID of the user or users to update power levels of
* @param powerLevel - the numeric power level to update given users to
* @param event - deprecated and no longer used.
* @returns Promise which resolves: to an ISendEventResponse object
* @returns Rejects: with an error response.
*/
public setPowerLevel(
public async setPowerLevel(
roomId: string,
userId: string | string[],
powerLevel: number | undefined,
event: MatrixEvent | null,
/**
* @deprecated no longer needed, unused.
*/
event?: MatrixEvent | null,
): Promise<ISendEventResponse> {
let content = {
users: {} as Record<string, number>,
};
if (event?.getType() === EventType.RoomPowerLevels) {
// take a copy of the content to ensure we don't corrupt
// existing client state with a failed power level change
content = utils.deepCopy(event.getContent());
let content: IPowerLevelsContent | undefined;
if (this.clientRunning && this.isInitialSyncComplete()) {
content = this.getRoom(roomId)?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
}
if (!content) {
try {
content = await this.getStateEvent(roomId, EventType.RoomPowerLevels, "");
} catch (e) {
// It is possible for a Matrix room to not have a power levels event
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
content = {};
} else {
throw e;
}
}
}
// take a copy of the content to ensure we don't corrupt
// existing client state with a failed power level change
content = utils.deepCopy(content);
if (!content?.users) {
content.users = {};
}
const users = Array.isArray(userId) ? userId : [userId];
for (const user of users) {
if (powerLevel == null) {
@@ -4283,10 +4361,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
$roomId: roomId,
});
return this.http.authedRequest(Method.Put, path, undefined, content);
return this.sendStateEvent(roomId, EventType.RoomPowerLevels, content, "");
}
/**
@@ -4382,7 +4457,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private sendCompleteEvent(
roomId: string,
threadId: string | null,
eventObject: any,
eventObject: Partial<IEvent>,
txnId?: string,
): Promise<ISendEventResponse> {
if (!txnId) {
@@ -4959,7 +5034,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> {
public async sendReceipt(
event: MatrixEvent,
receiptType: ReceiptType,
body?: Record<string, any>,
unthreaded = false,
): Promise<{}> {
if (this.isGuest()) {
return Promise.resolve({}); // guests cannot send receipts so don't bother.
}
@@ -4970,10 +5050,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: event.getId()!,
});
if (!unthreaded) {
const isThread = !!event.threadRootId;
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,
};
}
@@ -5096,7 +5185,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
},
undefined,
{
prefix: MediaPrefix.R0,
prefix: MediaPrefix.V3,
priority: "low",
},
);
// TODO: Expire the URL preview cache sometimes
@@ -5117,7 +5207,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$roomId: roomId,
$userId: this.getUserId()!,
});
const data: any = {
const data: QueryDict = {
typing: isTyping,
};
if (isTyping) {
@@ -5262,7 +5352,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
address: address,
};
if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
if (this.identityServer?.getAccessToken) {
const identityAccessToken = await this.identityServer.getAccessToken();
if (identityAccessToken) {
params["id_access_token"] = identityAccessToken;
@@ -5309,7 +5399,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const populationResults: { [roomId: string]: Error } = {};
const promises: Promise<any>[] = [];
const promises: Promise<unknown>[] = [];
const doLeave = (roomId: string): Promise<void> => {
return this.leave(roomId)
@@ -5510,6 +5600,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
}
/**
* Specify the set_presence value to be used for subsequent calls to the Sync API.
* This has an advantage over calls to the PUT /presence API in that it
* doesn't clobber status_msg set by other devices.
* @param presence - the presence to specify to set_presence of sync calls
*/
public async setSyncPresence(presence?: SetPresence): Promise<void> {
this.syncApi?.setPresence(presence);
}
/**
* @param opts - Options to apply
* @returns Promise which resolves
@@ -5902,7 +6002,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("getLatestTimeline only supports room timelines");
}
let event;
let event: IRoomEvent | undefined;
if (timelineSet.threadListType !== null) {
const res = await this.createThreadListMessagesRequest(
timelineSet.room.roomId,
@@ -6044,7 +6144,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const opts = {
prefix:
Thread.hasServerSideListSupport === FeatureSupport.Stable
? "/_matrix/client/v1"
? ClientPrefix.V1
: "/_matrix/client/unstable/org.matrix.msc3856",
};
@@ -6361,7 +6461,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
"",
);
let readPromise: Promise<any> = Promise.resolve<any>(undefined);
let readPromise: Promise<unknown> = Promise.resolve();
if (opts.allowRead) {
readPromise = this.sendStateEvent(
roomId,
@@ -6566,24 +6666,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
private async requestTokenFromEndpoint<T extends IRequestTokenResponse>(
endpoint: string,
params: Record<string, any>,
params: QueryDict,
): Promise<T> {
const postParams = Object.assign({}, params);
// If the HS supports separate add and bind, then requestToken endpoints
// don't need an IS as they are all validated by the HS directly.
if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) {
const idServerUrl = new URL(this.idBaseUrl);
postParams.id_server = idServerUrl.host;
if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
const identityAccessToken = await this.identityServer.getAccessToken();
if (identityAccessToken) {
postParams.id_access_token = identityAccessToken;
}
}
}
return this.http.request(Method.Post, endpoint, undefined, postParams);
}
@@ -7288,78 +7374,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return versions && versions.includes(version);
}
/**
* Query the server to see if it supports members lazy loading
* @returns true if server supports lazy loading
*/
public async doesServerSupportLazyLoading(): Promise<boolean> {
const response = await this.getVersions();
if (!response) return false;
const versions = response["versions"];
const unstableFeatures = response["unstable_features"];
return (
(versions && versions.includes("r0.5.0")) || (unstableFeatures && unstableFeatures["m.lazy_load_members"])
);
}
/**
* Query the server to see if the `id_server` parameter is required
* when registering with an 3pid, adding a 3pid or resetting password.
* @returns true if id_server parameter is required
*/
public async doesServerRequireIdServerParam(): Promise<boolean> {
const response = await this.getVersions();
if (!response) return true;
const versions = response["versions"];
// Supporting r0.6.0 is the same as having the flag set to false
if (versions && versions.includes("r0.6.0")) {
return false;
}
const unstableFeatures = response["unstable_features"];
if (!unstableFeatures) return true;
if (unstableFeatures["m.require_identity_server"] === undefined) {
return true;
} else {
return unstableFeatures["m.require_identity_server"];
}
}
/**
* Query the server to see if the `id_access_token` parameter can be safely
* passed to the homeserver. Some homeservers may trigger errors if they are not
* prepared for the new parameter.
* @returns true if id_access_token can be sent
*/
public async doesServerAcceptIdentityAccessToken(): Promise<boolean> {
const response = await this.getVersions();
if (!response) return false;
const versions = response["versions"];
const unstableFeatures = response["unstable_features"];
return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.id_access_token"]);
}
/**
* Query the server to see if it supports separate 3PID add and bind functions.
* This affects the sequence of API calls clients should use for these operations,
* so it's helpful to be able to check for support.
* @returns true if separate functions are supported
*/
public async doesServerSupportSeparateAddAndBind(): Promise<boolean> {
const response = await this.getVersions();
if (!response) return false;
const versions = response["versions"];
const unstableFeatures = response["unstable_features"];
return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
}
/**
* Query the server to see if it lists support for an unstable feature
* in the /versions response
@@ -7431,14 +7445,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
/**
* Query the server to see if it supports the MSC2457 `logout_devices` parameter when setting password
* @returns true if server supports the `logout_devices` parameter
*/
public doesServerSupportLogoutDevices(): Promise<boolean> {
return this.isVersionSupported("r0.6.1");
}
/**
* Get if lazy loading members is being used.
* @returns Whether or not members are lazy loaded by this client
@@ -7823,18 +7829,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
}
/**
* @param relayState - URL Callback after SAML2 Authentication
* @returns Promise which resolves to a LoginResponse object
* @returns Rejects: with an error response.
* @deprecated this isn't in the Matrix spec anymore
*/
public loginWithSAML2(relayState: string): Promise<LoginResponse> {
return this.login("m.login.saml2", {
relay_state: relayState,
});
}
/**
* @param redirectUrl - The URL to redirect to after the HS
* authenticates with CAS.
@@ -7864,7 +7858,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
[SSO_ACTION_PARAM.unstable!]: action,
};
return this.http.getUrl(url, params, ClientPrefix.R0).href;
return this.http.getUrl(url, params).href;
}
/**
@@ -7916,8 +7910,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* to false.
* @returns Promise which resolves: On success, the empty object
*/
public deactivateAccount(auth?: any, erase?: boolean): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
const body: any = {};
public deactivateAccount(
auth?: AuthDict,
erase?: boolean,
): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
const body: Body = {};
if (auth) {
body.auth = auth;
}
@@ -7979,13 +7976,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$loginType: loginType,
});
return this.http.getUrl(
path,
{
session: authSessionId,
},
ClientPrefix.R0,
).href;
return this.http.getUrl(path, {
session: authSessionId,
}).href;
}
/**
@@ -8000,11 +7993,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// inject the id_access_token if inviting 3rd party addresses
const invitesNeedingToken = (options.invite_3pid || []).filter((i) => !i.id_access_token);
if (
invitesNeedingToken.length > 0 &&
this.identityServer?.getAccessToken &&
(await this.doesServerAcceptIdentityAccessToken())
) {
if (invitesNeedingToken.length > 0 && this.identityServer?.getAccessToken) {
const identityAccessToken = await this.identityServer.getAccessToken();
if (identityAccessToken) {
for (const invite of invitesNeedingToken) {
@@ -8155,7 +8144,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public sendStateEvent(
roomId: string,
eventType: string,
content: any,
content: IContent,
stateKey = "",
opts: IRequestOpts = {},
): Promise<ISendEventResponse> {
@@ -8363,30 +8352,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Put, path, undefined, { visibility });
}
/**
* Set the visbility of a room bridged to a 3rd party network in
* the current HS's room directory.
* @param networkId - the network ID of the 3rd party
* instance under which this room is published under.
* @param visibility - "public" to make the room visible
* in the public directory, or "private" to make
* it invisible.
* @returns Promise which resolves: result object
* @returns Rejects: with an error response.
*/
public setRoomDirectoryVisibilityAppService(
networkId: string,
roomId: string,
visibility: "public" | "private",
): Promise<any> {
// TODO: Types
const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
$networkId: networkId,
$roomId: roomId,
});
return this.http.authedRequest(Method.Put, path, undefined, { visibility: visibility });
}
/**
* Query the user directory with a term matching user IDs, display names and domains.
* @param term - the term with which to search.
@@ -8395,7 +8360,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: an array of results.
*/
public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise<IUserDirectoryResponse> {
const body: any = {
const body: Body = {
search_term: term,
};
@@ -8469,33 +8434,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Get, "/account/3pid");
}
/**
* Add a 3PID to your homeserver account and optionally bind it to an identity
* server as well. An identity server is required as part of the `creds` object.
*
* This API is deprecated, and you should instead use `addThreePidOnly`
* for homeservers that support it.
*
* @returns Promise which resolves: on success
* @returns Rejects: with an error response.
*/
public addThreePid(creds: any, bind: boolean): Promise<any> {
// TODO: Types
const path = "/account/3pid";
const data = {
threePidCreds: creds,
bind: bind,
};
return this.http.authedRequest(Method.Post, path, undefined, data);
}
/**
* Add a 3PID to your homeserver account. This API does not use an identity
* server, as the homeserver is expected to handle 3PID ownership validation.
*
* You can check whether a homeserver supports this API via
* `doesServerSupportSeparateAddAndBind`.
*
* @param data - A object with 3PID validation data from having called
* `account/3pid/<medium>/requestToken` on the homeserver.
* @returns Promise which resolves: to an empty object `{}`
@@ -8503,8 +8445,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
const path = "/account/3pid/add";
const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
return this.http.authedRequest(Method.Post, path, undefined, data);
}
/**
@@ -8512,9 +8453,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* identity server handles 3PID ownership validation and the homeserver records
* the new binding to track where all 3PIDs for the account are bound.
*
* You can check whether a homeserver supports this API via
* `doesServerSupportSeparateAddAndBind`.
*
* @param data - A object with 3PID validation data from having called
* `validate/<medium>/requestToken` on the identity server. It should also
* contain `id_server` and `id_access_token` fields as well.
@@ -8523,8 +8461,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async bindThreePid(data: IBindThreePidBody): Promise<{}> {
const path = "/account/3pid/bind";
const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
return this.http.authedRequest(Method.Post, path, undefined, data);
}
/**
@@ -8549,8 +8486,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
address,
id_server: this.getIdentityServerUrl(true),
};
const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
return this.http.authedRequest(Method.Post, path, undefined, data);
}
/**
@@ -8640,7 +8576,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$device_id: deviceId,
});
const body: any = {};
const body: Body = {};
if (auth) {
body.auth = auth;
@@ -8658,7 +8594,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects: with an error response.
*/
public deleteMultipleDevices(devices: string[], auth?: AuthDict): Promise<{}> {
const body: any = { devices };
const body: Body = { devices };
if (auth) {
body.auth = auth;
@@ -8835,7 +8771,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
{ body, next_batch: nextBatch }: { body: ISearchRequestBody; next_batch?: string },
abortSignal?: AbortSignal,
): Promise<ISearchResponse> {
const queryParams: any = {};
const queryParams: QueryDict = {};
if (nextBatch) {
queryParams.next_batch = nextBatch;
}
@@ -8858,9 +8794,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> {
return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, {
prefix: ClientPrefix.V3,
});
return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content);
}
/**
@@ -9455,7 +9389,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* response to getThirdpartyProtocols()
* @returns Promise which resolves to the result object
*/
public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> {
public getThirdpartyUser(protocol: string, params?: QueryDict): Promise<IThirdPartyUser[]> {
const path = utils.encodeUri("/thirdparty/user/$protocol", {
$protocol: protocol,
});
@@ -9631,7 +9565,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
proxyBaseUrl?: string,
abortSignal?: AbortSignal,
): Promise<MSC3575SlidingSyncResponse> {
const qps: Record<string, any> = {};
const qps: QueryDict = {};
if (req.pos) {
qps.pos = req.pos;
delete req.pos;
@@ -9705,6 +9639,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param toStartOfTimeline - the direction
*/
public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
if (!this.supportsThreads()) return;
room.processThreadRoots(threadedEvents, toStartOfTimeline);
}
@@ -9795,19 +9730,8 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
const room = cli.getRoom(event.getRoomId());
if (!room || !ourUserId || !eventId) return;
const oldActions = event.getPushActions();
const actions = cli.getPushActionsForEvent(event, true);
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
const currentHighlightCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
// Ensure the unread counts are kept up to date if the event is encrypted
// We also want to make sure that the notification count goes up if we already
// have encrypted events to avoid other code from resetting 'highlight' to zero.
const oldHighlight = !!oldActions?.tweaks?.highlight;
const newHighlight = !!actions?.tweaks?.highlight;
let hasReadEvent;
if (isThreadEvent) {
const thread = room.getThread(event.threadRootId);
@@ -9829,13 +9753,17 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
return;
}
if (oldHighlight !== newHighlight || currentHighlightCount > 0) {
const actions = cli.getPushActionsForEvent(event, true);
// Ensure the unread counts are kept up to date if the event is encrypted
// We also want to make sure that the notification count goes up if we already
// have encrypted events to avoid other code from resetting 'highlight' to zero.
const newHighlight = !!actions?.tweaks?.highlight;
if (newHighlight) {
// TODO: Handle mentions received while the client is offline
// See also https://github.com/vector-im/element-web/issues/9069
let newCount = currentHighlightCount;
if (newHighlight && !oldHighlight) newCount++;
if (!newHighlight && oldHighlight) newCount--;
const newCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event) + 1;
if (isThreadEvent) {
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
} else {
@@ -9843,23 +9771,18 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
}
}
// Total count is used to typically increment a room notification counter, but not loudly highlight it.
const currentTotalCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event);
// `notify` is used in practice for incrementing the total count
const newNotify = !!actions?.notify;
// The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore
// the server here as it's always going to tell us to increment for encrypted events.
if (newNotify) {
// Total count is used to typically increment a room notification counter, but not loudly highlight it.
const newCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event) + 1;
if (isThreadEvent) {
room.setThreadUnreadNotificationCount(
event.threadRootId,
NotificationCountType.Total,
currentTotalCount + 1,
);
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total, newCount);
} else {
room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1);
room.setUnreadNotificationCount(NotificationCountType.Total, newCount);
}
}
}
+25 -1
View File
@@ -24,6 +24,8 @@ import { IEventDecryptionResult } from "../@types/crypto";
/**
* Common interface for the crypto implementations
*
* @internal
*/
export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
/**
@@ -88,9 +90,21 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @returns the cross signing information for the user.
*/
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
/**
* Check the cross signing trust of the current user
*
* @param opts - Options object.
*
* @deprecated Unneeded for the new crypto
*/
checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api */
/** The methods which crypto implementations should expose to the Sync api
*
* @internal
*/
export interface SyncCryptoCallbacks {
/**
* Called by the /sync loop whenever there are incoming to-device messages.
@@ -146,6 +160,9 @@ export interface SyncCryptoCallbacks {
onSyncCompleted(syncState: OnSyncCompletedData): void;
}
/**
* @internal
*/
export interface OnSyncCompletedData {
/**
* The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
@@ -157,3 +174,10 @@ export interface OnSyncCompletedData {
*/
catchingUp?: boolean;
}
/**
* Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}.
*/
export interface CheckOwnCrossSigningTrustOpts {
allowPrivateKeyRequests?: boolean;
}
+2 -2
View File
@@ -49,7 +49,7 @@ export function getHttpUriForMxc(
}
}
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix = "/_matrix/media/r0/download/";
let prefix = "/_matrix/media/v3/download/";
const params: Record<string, string> = {};
if (width) {
@@ -64,7 +64,7 @@ export function getHttpUriForMxc(
if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the
// thumbnailing API...
prefix = "/_matrix/media/r0/thumbnail/";
prefix = "/_matrix/media/v3/thumbnail/";
}
const fragmentOffset = serverAndMediaId.indexOf("#");
+99 -1
View File
@@ -20,7 +20,8 @@ import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { VerificationRequest } from "./crypto-api/verification";
import { KeyBackupInfo } from "./crypto-api/keybackup";
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
import { ISignatures } from "./@types/signed";
/**
* Public interface to the cryptography parts of the js-sdk
@@ -136,6 +137,22 @@ export interface CryptoApi {
*/
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
/**
* Mark the given device as locally verified.
*
* Marking a devices as locally verified has much the same effect as completing the verification dance, or receiving
* a cross-signing signature for it.
*
* @param userId - owner of the device
* @param deviceId - unique identifier for the device.
* @param verified - whether to mark the device as verified. Defaults to 'true'.
*
* @throws an error if the device is unknown, or has not published any encryption keys.
*
* @remarks Fires {@link CryptoEvent#DeviceVerificationChanged}
*/
setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise<void>;
/**
* Checks whether cross signing:
* - is enabled on this account and trusted by this device
@@ -250,9 +267,30 @@ export interface CryptoApi {
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
* @deprecated prefer `userId` parameter variant.
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Finds a DM verification request that is already in progress for the given room and user.
*
* @param roomId - the room to use for verification.
* @param userId - search for a verification request for the given user.
*
* @returns the VerificationRequest that is in progress, if any.
*/
findVerificationRequestDMInProgress(roomId: string, userId?: string): VerificationRequest | undefined;
/**
* Request a key verification from another user, using a DM.
*
* @param userId - the user to request verification with.
* @param roomId - the room to use for verification.
*
* @returns resolves to a VerificationRequest when the request has been sent to the other party.
*/
requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest>;
/**
* Send a verification request to our other devices.
*
@@ -284,6 +322,55 @@ export interface CryptoApi {
* @returns a VerificationRequest when the request has been sent to the other party.
*/
requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Secure key backup
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Fetch the backup decryption key we have saved in our store.
*
* This can be used for gossiping the key to other devices.
*
* @returns the key, if any, or null
*/
getSessionBackupPrivateKey(): Promise<Uint8Array | null>;
/**
* Store the backup decryption key.
*
* This should be called if the client has received the key from another device via secret sharing (gossiping).
*
* @param key - the backup decryption key
*/
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
/**
* Get the current status of key backup.
*
* @returns If automatic key backups are enabled, the `version` of the active backup. Otherwise, `null`.
*/
getActiveSessionBackupVersion(): Promise<string | null>;
/**
* Determine if a key backup can be trusted.
*
* @param info - key backup info dict from {@link MatrixClient#getKeyBackupVersion}.
*/
isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo>;
/**
* Force a re-check of the key backup and enable/disable it as appropriate.
*
* Fetches the current backup information from the server. If there is a backup, and it is trusted, starts
* backing up to it; otherwise, disables backups.
*
* @returns `null` if there is no backup on the server. Otherwise, data on the backup as returned by the server,
* and trust information (as returned by {@link isKeyBackupTrusted}).
*/
checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null>;
}
/**
@@ -479,6 +566,17 @@ export enum CrossSigningKey {
UserSigning = "user_signing",
}
/**
* Information on one of the cross-signing keys.
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3keysdevice_signingupload
*/
export interface CrossSigningKeyInfo {
keys: { [algorithm: string]: string };
signatures?: ISignatures;
usage: string[];
user_id: string;
}
/**
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
*/
+29 -1
View File
@@ -31,7 +31,10 @@ export interface Aes256AuthData {
}
/**
* Extra info of a recovery key
* Information about a server-side key backup.
*
* Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion)
* and hence {@link MatrixClient#getKeyBackupVersion}.
*/
export interface KeyBackupInfo {
algorithm: string;
@@ -40,3 +43,28 @@ export interface KeyBackupInfo {
etag?: string;
version?: string; // number contained within
}
/**
* Information on whether a given server-side backup is trusted.
*/
export interface BackupTrustInfo {
/**
* Is this backup trusted?
*
* True if, and only if, there is a valid signature on the backup from a trusted device.
*/
readonly trusted: boolean;
/**
* True if this backup matches the stored decryption key.
*/
readonly matchesDecryptionKey: boolean;
}
/**
* The result of {@link CryptoApi.checkKeyBackupAndEnable}.
*/
export interface KeyBackupCheck {
backupInfo: KeyBackupInfo;
trustInfo: BackupTrustInfo;
}
+27 -2
View File
@@ -136,12 +136,27 @@ export interface VerificationRequest
/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
* This is normally used when starting a verification via emojis (ie, `method` is set to `m.sas.v1`).
*
* @param method - the name of the verification method to use.
*
* @returns The verifier which will do the actual verification.
*/
startVerification(method: string): Promise<Verifier>;
/**
* Start a QR code verification by providing a scanned QR code for this verification flow.
*
* Validates the QR code, and if it is ok, sends an `m.key.verification.start` event with `method` set to
* `m.reciprocate.v1`, to tell the other side the scan was successful.
*
* See also {@link VerificationRequest#startVerification} which can be used to start other verification methods.
*
* @param qrCodeData - the decoded QR code.
* @returns A verifier; call `.verify()` on it to wait for the other side to complete the verification flow.
*/
scanQRCode(qrCodeData: Uint8Array): Promise<Verifier>;
/**
* The verifier which is doing the actual verification, once the method has been established.
* Only defined when the `phase` is Started.
@@ -152,9 +167,19 @@ export interface VerificationRequest
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
*/
getQRCodeBytes(): Buffer | undefined;
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only returns data once `phase` is {@link VerificationPhase.Ready} and the other party can scan a QR code;
* otherwise returns `undefined`.
*/
generateQRCode(): Promise<Buffer | undefined>;
/**
* If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling
* this verification.
@@ -285,7 +310,7 @@ export enum VerifierEvent {
ShowSas = "show_sas",
/**
* QR code data should be displayed to the user.
* The user should confirm if the other side has scanned our QR code.
*
* The payload is the {@link ShowQrCodeCallbacks} object.
*/
@@ -300,7 +325,7 @@ export type VerifierEventHandlerMap = {
};
/**
* Callbacks for user actions while a QR code is displayed.
* Callbacks for user actions to confirm that the other side has scanned our QR code.
*
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
* verifier as `reciprocateQREvent`.
+4 -5
View File
@@ -18,8 +18,7 @@ limitations under the License.
* Cross signing methods
*/
import { PkSigning } from "@matrix-org/olm";
import type { PkSigning } from "@matrix-org/olm";
import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
import { logger } from "../logger";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
@@ -245,7 +244,7 @@ export class CrossSigningInfo {
* @returns A map from key type (string) to private key (Uint8Array)
*/
public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> {
const keys = new Map();
const keys = new Map<string, Uint8Array>();
const cacheCallbacks = this.cacheCallbacks;
if (!cacheCallbacks) return keys;
for (const type of ["master", "self_signing", "user_signing"]) {
@@ -294,8 +293,8 @@ export class CrossSigningInfo {
const privateKeys: Record<string, Uint8Array> = {};
const keys: Record<string, ICrossSigningKey> = {};
let masterSigning;
let masterPub;
let masterSigning: PkSigning | undefined;
let masterPub: string | undefined;
try {
if (level & CrossSigningLevel.MASTER) {
+1 -1
View File
@@ -188,7 +188,7 @@ export class EncryptionSetupOperation {
// We must only call `uploadDeviceSigningKeys` from inside this auth
// helper to ensure we properly handle auth errors.
await this.crossSigningKeys.authUpload?.((authDict) => {
return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys);
return baseApis.uploadDeviceSigningKeys(authDict ?? undefined, keys as CrossSigningKeys);
});
// pass the new keys to the main instance of our own CrossSigningInfo.
+1 -1
View File
@@ -1119,7 +1119,7 @@ export class OlmDevice {
}
}
logger.info(
logger.debug(
`Storing megolm session ${senderKey}|${sessionId} with first index ` +
session.first_known_index(),
);
+33 -11
View File
@@ -40,6 +40,7 @@ import { UnstableValue } from "../NamespacedValue";
import { CryptoEvent } from "./index";
import { crypto } from "./crypto";
import { HTTPError, MatrixError } from "../http-api";
import { BackupTrustInfo } from "../crypto-api/keybackup";
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
@@ -54,6 +55,7 @@ type SigInfo = {
deviceTrust?: DeviceTrustLevel;
};
/** @deprecated Prefer {@link BackupTrustInfo} */
export type TrustInfo = {
usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[];
@@ -438,6 +440,7 @@ export class BackupManager {
* @param maxDelay - Maximum delay to wait in ms. 0 means no delay.
*/
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
logger.debug(`Key backup: scheduleKeyBackupSend currentSending:${this.sendingBackups} delay:${maxDelay}`);
if (this.sendingBackups) return;
this.sendingBackups = true;
@@ -450,6 +453,7 @@ export class BackupManager {
await sleep(delay);
if (!this.clientRunning) {
logger.debug("Key backup send aborted, client stopped");
this.sendingBackups = false;
return;
}
let numFailures = 0; // number of consecutive failures
@@ -461,24 +465,26 @@ export class BackupManager {
const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
this.sendingBackups = false;
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if ((<MatrixError>err).data) {
if (
(<MatrixError>err).data.errcode == "M_NOT_FOUND" ||
(<MatrixError>err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION"
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
if (err instanceof MatrixError) {
const errCode = err.data.errcode;
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
// Set to false now as `checkKeyBackup` might schedule a backupsend before this one ends.
this.sendingBackups = false;
// Backup version has changed or this backup version
// has been deleted
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, (<MatrixError>err).data.errcode!);
throw err;
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, errCode);
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
// This call might restart the backup loop if new backup version is trusted
await this.checkKeyBackup();
return;
}
}
}
@@ -489,10 +495,14 @@ export class BackupManager {
if (!this.clientRunning) {
logger.debug("Key backup send loop aborted, client stopped");
this.sendingBackups = false;
return;
}
}
} finally {
} catch (err) {
// No one actually checks errors on this promise, it's spawned internally.
// Just log, apps/client should use events to check status
logger.log(`Backup loop failed ${err}`);
this.sendingBackups = false;
}
}
@@ -829,3 +839,15 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
};
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
/**
* Map a legacy {@link TrustInfo} into a new-style {@link BackupTrustInfo}.
*
* @param trustInfo - trustInfo to convert
*/
export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): BackupTrustInfo {
return {
trusted: trustInfo.usable,
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
};
}
+2
View File
@@ -28,6 +28,8 @@ export interface IDevice {
/**
* Information about a user's device
*
* Superceded by {@link Device}.
*/
export class DeviceInfo {
/**
+62 -13
View File
@@ -48,9 +48,9 @@ import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChan
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
import { DehydrationManager } from "./dehydration";
import { BackupManager } from "./backup";
import { BackupManager, backupTrustInfoFromLegacyTrustInfo } from "./backup";
import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
@@ -87,10 +87,13 @@ import {
} from "../secret-storage";
import { ISecretRequest } from "./SecretSharing";
import {
BackupTrustInfo,
BootstrapCrossSigningOpts,
CrossSigningStatus,
DeviceVerificationStatus,
ImportRoomKeysOpts,
KeyBackupCheck,
KeyBackupInfo,
VerificationRequest as CryptoApiVerificationRequest,
} from "../crypto-api";
import { Device, DeviceMap } from "../models/device";
@@ -1242,21 +1245,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @returns the key, if any, or null
*/
public async getSessionBackupPrivateKey(): Promise<Uint8Array | null> {
let key = await new Promise<any>((resolve) => {
// TODO types
const encodedKey = await new Promise<Uint8Array | IEncryptedPayload | string | null>((resolve) => {
this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1");
});
});
let key: Uint8Array | null = null;
// make sure we have a Uint8Array, rather than a string
if (key && typeof key === "string") {
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key));
if (typeof encodedKey === "string") {
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(encodedKey) || encodedKey));
await this.storeSessionBackupPrivateKey(key);
}
if (key && key.ciphertext) {
if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) {
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1");
const decrypted = await decryptAES(encodedKey, pickleKey, "m.megolm_backup.v1");
key = olmlib.decodeBase64(decrypted);
}
return key;
@@ -1279,6 +1283,42 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
}
/**
* Get the current status of key backup.
*
* Implementation of {@link CryptoApi.getActiveSessionBackupVersion}.
*/
public async getActiveSessionBackupVersion(): Promise<string | null> {
if (this.backupManager.getKeyBackupEnabled()) {
return this.backupManager.version ?? null;
}
return null;
}
/**
* Determine if a key backup can be trusted.
*
* Implementation of {@link Crypto.CryptoApi.isKeyBackupTrusted}.
*/
public async isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo> {
const trustInfo = await this.backupManager.isKeyBackupTrusted(info);
return backupTrustInfoFromLegacyTrustInfo(trustInfo);
}
/**
* Force a re-check of the key backup and enable/disable it as appropriate.
*
* Implementation of {@link CryptoApi.checkKeyBackupAndEnable}.
*/
public async checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null> {
const checkResult = await this.backupManager.checkKeyBackup();
if (!checkResult || !checkResult.backupInfo) return null;
return {
backupInfo: checkResult.backupInfo,
trustInfo: backupTrustInfoFromLegacyTrustInfo(checkResult.trustInfo),
};
}
/**
* Checks that a given cross-signing private key matches a given public key.
* This can be used by the getCrossSigningKey callback to verify that the
@@ -2173,6 +2213,15 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.deviceList.saveIfDirty(delay);
}
/**
* Mark the given device as locally verified.
*
* Implementation of {@link CryptoApi#setDeviceVerified}.
*/
public async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
await this.setDeviceVerification(userId, deviceId, verified);
}
/**
* Update the blocked/verified state of the given device
*
@@ -2202,12 +2251,12 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
blocked: boolean | null = null,
known: boolean | null = null,
keys?: Record<string, string>,
): Promise<DeviceInfo | CrossSigningInfo> {
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
// Check if the 'device' is actually a cross signing key
// The js-sdk's verification treats cross-signing keys as devices
// and so uses this method to mark them verified.
const xsk = this.deviceList.getStoredCrossSigningForUser(userId);
if (xsk && xsk.getId() === deviceId) {
if (xsk?.getId() === deviceId) {
if (blocked !== null || known !== null) {
throw new Error("Cannot set blocked or known for a cross-signing key");
}
@@ -2257,7 +2306,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// This will emit events when it comes back down the sync
// (we could do local echo to speed things up)
}
return device as any; // TODO types
return device!;
} else {
return xsk;
}
@@ -2346,8 +2395,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return deviceObj;
}
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
return this.inRoomVerificationRequests.findRequestInProgress(roomId);
public findVerificationRequestDMInProgress(roomId: string, userId?: string): VerificationRequest | undefined {
return this.inRoomVerificationRequests.findRequestInProgress(roomId, userId);
}
public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
@@ -350,11 +350,11 @@ export class InRoomRequests implements IRequestsMap {
}
}
public findRequestInProgress(roomId: string): VerificationRequest | undefined {
public findRequestInProgress(roomId: string, userId?: string): VerificationRequest | undefined {
const requestsByTxnId = this.requestsByRoomId.get(roomId);
if (requestsByTxnId) {
for (const request of requestsByTxnId.values()) {
if (request.pending) {
if (request.pending && (userId === undefined || request.requestingUserId === userId)) {
return request;
}
}
@@ -268,7 +268,7 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
/** Only set after a .ready if the other party can scan a QR code
*
* @deprecated Prefer `getQRCodeBytes`.
* @deprecated Prefer `generateQRCode`.
*/
public get qrCodeData(): QRCodeData | null {
return this._qrCodeData;
@@ -278,11 +278,23 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Prefer `generateQRCode`.
*/
public getQRCodeBytes(): Buffer | undefined {
return this._qrCodeData?.getBuffer();
}
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only returns data once `phase` is `Ready` and the other party can scan a QR code;
* otherwise returns `undefined`.
*/
public async generateQRCode(): Promise<Buffer | undefined> {
return this.getQRCodeBytes();
}
/** Checks whether the other party supports a given verification method.
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
@@ -466,6 +478,10 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
return verifier;
}
public scanQRCode(qrCodeData: Uint8Array): Promise<Verifier> {
throw new Error("QR code scanning not supported by legacy crypto");
}
/**
* sends the initial .request event.
* @returns resolves when the event has been sent.
+3 -1
View File
@@ -97,7 +97,7 @@ export interface ICapabilities {
*/
export class RoomWidgetClient extends MatrixClient {
private room?: Room;
private widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
private readonly widgetApiReady: Promise<void>;
private lifecycle?: AbortController;
private syncState: SyncState | null = null;
@@ -109,6 +109,8 @@ export class RoomWidgetClient extends MatrixClient {
) {
super(opts);
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
+1
View File
@@ -63,6 +63,7 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
},
[Feature.IntentionalMentions]: {
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
matrixVersion: "v1.7",
},
};
+33 -4
View File
@@ -22,11 +22,10 @@ import { checkObjectHasKeys, encodeParams } from "../utils";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { Method } from "./method";
import { ConnectionError, MatrixError } from "./errors";
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface";
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts, Body } from "./interface";
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
import { QueryDict } from "../utils";
type Body = Record<string, any> | BodyInit;
import { logger } from "../logger";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
@@ -223,8 +222,11 @@ export class FetchHttpApi<O extends IHttpOpts> {
method: Method,
url: URL | string,
body?: Body,
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {},
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
): Promise<ResponseType<T, O>> {
const urlForLogs = this.sanitizeUrlForLogs(url);
logger.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
@@ -260,6 +262,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
const { signal, cleanup } = anySignal(signals);
let res: Response;
const start = Date.now();
try {
res = await this.fetch(url, {
signal,
@@ -273,8 +276,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
cache: "no-cache",
credentials: "omit", // we send credentials via headers
keepalive: keepAlive,
priority: opts.priority,
});
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
} catch (e) {
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
if ((<Error>e).name === "AbortError") {
throw e;
}
@@ -293,6 +300,28 @@ export class FetchHttpApi<O extends IHttpOpts> {
return res as ResponseType<T, O>;
}
private sanitizeUrlForLogs(url: URL | string): string {
try {
let asUrl: URL;
if (typeof url === "string") {
asUrl = new URL(url);
} else {
asUrl = url;
}
// Remove the values of any URL params that could contain potential secrets
const sanitizedQs = new URLSearchParams();
for (const key of asUrl.searchParams.keys()) {
sanitizedQs.append(key, "xxx");
}
const sanitizedQsString = sanitizedQs.toString();
const sanitizedQsUrlPiece = sanitizedQsString ? `?${sanitizedQsString}` : "";
return asUrl.origin + asUrl.pathname + sanitizedQsUrlPiece;
} catch (error) {
// defensive coding for malformed url
return "??";
}
}
/**
* Form and return a homeserver request URL based on the given path params and prefix.
* @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
+3 -3
View File
@@ -110,7 +110,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
});
};
const url = this.getUrl("/upload", undefined, MediaPrefix.R0);
const url = this.getUrl("/upload", undefined, MediaPrefix.V3);
if (includeFilename && fileName) {
url.searchParams.set("filename", encodeURIComponent(fileName));
@@ -139,7 +139,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
const headers: Record<string, string> = { "Content-Type": contentType };
this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, {
prefix: MediaPrefix.R0,
prefix: MediaPrefix.V3,
headers,
abortSignal: abortController.signal,
})
@@ -182,7 +182,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
public getContentUri(): IContentUri {
return {
base: this.opts.baseUrl,
path: MediaPrefix.R0 + "/upload",
path: MediaPrefix.V3 + "/upload",
params: {
access_token: this.opts.accessToken!,
},
+3 -1
View File
@@ -16,6 +16,8 @@ limitations under the License.
import { MatrixError } from "./errors";
export type Body = Record<string, any> | BodyInit;
export interface IHttpOpts {
fetchFn?: typeof global.fetch;
@@ -31,7 +33,7 @@ export interface IHttpOpts {
localTimeoutMs?: number;
}
export interface IRequestOpts {
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
/**
* The alternative base url to use.
* If not specified, uses this.opts.baseUrl
+7 -7
View File
@@ -16,11 +16,7 @@ limitations under the License.
export enum ClientPrefix {
/**
* A constant representing the URI path for release 0 of the Client-Server HTTP API.
*/
R0 = "/_matrix/client/r0",
/**
* A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API.
* A constant representing the URI path for Client-Server API endpoints versioned at v1.
*/
V1 = "/_matrix/client/v1",
/**
@@ -42,7 +38,11 @@ export enum IdentityPrefix {
export enum MediaPrefix {
/**
* URI path for the media repo API
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
*/
R0 = "/_matrix/media/r0",
V1 = "/_matrix/media/v3",
/**
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
*/
V3 = "/_matrix/media/v3",
}
+16 -9
View File
@@ -159,11 +159,12 @@ export class NoAuthFlowFoundError extends Error {
* The type of an application callback to perform the user-interactive bit of UIA.
*
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
* makes the HTTP request.
* makes the HTTP request. The `authData` parameter in `makeRequest` can be set to null to omit the `auth` field
* from the UIA request.
*
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
*/
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
interface IOpts<T> {
/**
@@ -263,8 +264,8 @@ export class InteractiveAuth<T> {
private readonly requestEmailTokenCallback: IOpts<T>["requestEmailToken"];
private readonly supportedStages?: Set<string>;
// The current latest data received from the server during the user interactive auth flow.
private data: IAuthData;
// The current latest data or error received from the server during the user interactive auth flow.
private data: IAuthData & MatrixError["data"];
private emailSid?: string;
private requestingEmailToken = false;
private attemptAuthDeferred: IDeferred<T> | null = null;
@@ -344,10 +345,8 @@ export class InteractiveAuth<T> {
sid: this.emailSid,
client_secret: this.clientSecret,
};
if (await this.matrixClient.doesServerRequireIdServerParam()) {
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
creds.id_server = idServerParsedUrl.host;
}
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
creds.id_server = idServerParsedUrl.host;
authDict = {
type: EMAIL_STAGE_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA
@@ -548,7 +547,7 @@ export class InteractiveAuth<T> {
matrixError.data.session = (this.data as IAuthData).session;
}
if (matrixError) {
this.data = matrixError.data as IAuthData;
this.data = matrixError.data;
}
try {
this.startNextAuthStage();
@@ -601,6 +600,14 @@ export class InteractiveAuth<T> {
return;
}
if (this.data?.errcode || this.data?.error) {
this.stateUpdatedCallback(nextStage, {
errcode: this.data?.errcode || "",
error: this.data?.error || "",
});
return;
}
this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE ? { emailSid: this.emailSid } : {});
}
+26 -3
View File
@@ -41,6 +41,7 @@ export * from "./models/thread";
export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./models/search-result";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
@@ -49,23 +50,45 @@ export * from "./service-types";
export * from "./store/memory";
export * from "./store/indexeddb";
export * from "./crypto/store/memory-crypto-store";
export * from "./crypto/store/localStorage-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store";
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
export * from "./content-repo";
export * from "./@types/uia";
export * from "./@types/event";
export * from "./@types/PushRules";
export * from "./@types/partials";
export * from "./@types/requests";
export * from "./@types/search";
export * from "./models/room-summary";
export * from "./models/event-status";
export * as ContentHelpers from "./content-helpers";
export * as SecretStorage from "./secret-storage";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
export { createNewMatrixCall } from "./webrtc/call";
export { createNewMatrixCall, CallEvent } from "./webrtc/call";
export type { MatrixCall } from "./webrtc/call";
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
export {
GroupCall,
GroupCallEvent,
GroupCallIntent,
GroupCallState,
GroupCallType,
GroupCallStatsReportEvent,
} from "./webrtc/groupCall";
export { CryptoEvent } from "./crypto";
export { SyncState, SetPresence } from "./sync";
export type { ISyncStateData as SyncStateData } from "./sync";
export { SlidingSyncEvent } from "./sliding-sync";
export { MediaHandlerEvent } from "./webrtc/mediaHandler";
export { CallFeedEvent } from "./webrtc/callFeed";
export { StatsReport } from "./webrtc/stats/statsReport";
export { Relations, RelationsEvent } from "./models/relations";
export { TypedEventEmitter } from "./models/typed-event-emitter";
export { LocalStorageErrors } from "./store/local-storage-events-emitter";
export { IdentityProviderBrand, SSOAction } from "./@types/auth";
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces";
export { LocationAssetType } from "./@types/location";
/**
* Types supporting cryptography.
+10 -1
View File
@@ -984,11 +984,20 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
);
}
const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event);
const { threadId, shouldLiveInRoom, shouldLiveInThread } = this.room.eventShouldLiveIn(event);
if (this.thread) {
return this.thread.id === threadId;
}
if (!shouldLiveInRoom && !shouldLiveInThread) {
logger.warn(
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${
this.room?.roomId
} eventId=${event.getId()} threadId=${event.threadRootId}`,
);
}
return shouldLiveInRoom;
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ export interface IContent {
"displayname"?: string;
"m.relates_to"?: IEventRelation;
"org.matrix.msc3952.mentions"?: IMentions;
"m.mentions"?: IMentions;
}
type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>;
+21 -19
View File
@@ -57,9 +57,10 @@ export abstract class ReadReceipt<
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
// Map: receipt type → user Id → receipt
private receipts = new MapWithDefault<string, Map<string, [WrappedReceipt | null, WrappedReceipt | null]>>(
() => new Map(),
);
private receipts = new MapWithDefault<
string,
Map<string, [realReceipt: WrappedReceipt | null, syntheticReceipt: WrappedReceipt | null]>
>(() => new Map());
private receiptCacheByEventId: ReceiptCache = new Map();
public abstract getUnfilteredTimelineSet(): EventTimelineSet;
@@ -85,6 +86,13 @@ export abstract class ReadReceipt<
return syntheticReceipt ?? realReceipt;
}
private compareReceipts(a: WrappedReceipt, b: WrappedReceipt): number {
// Try compare them in our unfiltered timeline set order, falling back to receipt timestamp which should be
// relatively sane as receipts are set only by the originating homeserver so as long as its clock doesn't
// jump around then it should be valid.
return this.getUnfilteredTimelineSet().compareEventOrdering(a.eventId, b.eventId) ?? a.data.ts - b.data.ts;
}
/**
* Get the ID of the event that a given user has read up to, or null if we
* have received no read receipts from them.
@@ -99,19 +107,13 @@ export abstract class ReadReceipt<
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// If we have both, compare them
let comparison: number | null | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) {
comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
comparison = this.compareReceipts(publicReadReceipt, privateReadReceipt);
}
// The public receipt is more likely to drift out of date so the private
@@ -142,20 +144,20 @@ export abstract class ReadReceipt<
existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
}
if (existingReceipt) {
// we only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(existingReceipt.eventId, eventId);
if (ordering !== null && ordering >= 0) {
return;
}
}
const wrappedReceipt: WrappedReceipt = {
eventId,
data: receipt,
};
if (existingReceipt) {
// We only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.compareReceipts(existingReceipt, wrappedReceipt);
if (ordering >= 0) {
return;
}
}
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
+37 -23
View File
@@ -1877,6 +1877,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Takes the given thread root events and creates threads for them.
*/
public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void {
if (!this.client.supportsThreads()) return;
for (const rootEvent of events) {
EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline);
if (!this.getThread(rootEvent.getId()!)) {
@@ -2031,6 +2032,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @internal
*/
private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
if (!this.client.supportsThreads()) return;
if (this.threadsTimelineSets.length === 0) return;
const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
@@ -2093,6 +2095,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
/**
* Determine which timeline(s) a given event should live in
* Thread roots live in both the main timeline and their corresponding thread timeline
* Relations, redactions, replies to thread relation events live only in the thread timeline
* Relations (other than m.thread), redactions, replies to a thread root live only in the main timeline
* Relations, redactions, replies where the parent cannot be found live in no timelines but should be aggregated regardless.
* Otherwise, the event lives in the main timeline only.
*/
public eventShouldLiveIn(
event: MatrixEvent,
events?: MatrixEvent[],
@@ -2109,7 +2119,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
// A thread root is always shown in both timelines
// A thread root is the only event shown in both timelines
if (event.isThreadRoot || roots?.has(event.getId()!)) {
return {
shouldLiveInRoom: true,
@@ -2118,8 +2128,29 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
// A thread relation (1st and 2nd order) is always only shown in a thread
const isThreadRelation = event.isRelation(THREAD_RELATION_TYPE.name);
const parentEventId = event.getAssociatedId();
const threadRootId = event.threadRootId;
// Where the parent is the thread root and this is a non-thread relation this should live only in the main timeline
if (!!parentEventId && !isThreadRelation && (threadRootId === parentEventId || roots?.has(parentEventId!))) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
let parentEvent: MatrixEvent | undefined;
if (parentEventId) {
parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId);
}
// Treat non-thread-relations, redactions, and replies as extensions of their parents so evaluate parentEvent instead
if (parentEvent && !isThreadRelation) {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
// A thread relation (1st and 2nd order) is always only shown in a thread
if (threadRootId != undefined) {
return {
shouldLiveInRoom: false,
@@ -2128,33 +2159,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
const parentEventId = event.getAssociatedId();
let parentEvent: MatrixEvent | undefined;
if (parentEventId) {
parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId);
}
// Treat relations and redactions as extensions of their parents so evaluate parentEvent instead
if (parentEvent && (event.isRelation() || event.isRedaction())) {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
if (!event.isRelation()) {
// Due to replies not being typical relations and being used as fallbacks for threads relations
// If we bypass the if case above then we know we are not a thread, so if we are still a reply
// then we know that we must be in the main timeline. Same goes if we have no associated parent event.
if (!parentEventId || !!event.replyEventId) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
// Edge case where we know the event is a relation but don't have the parentEvent
if (roots?.has(event.relationEventId!)) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId: event.relationEventId,
};
}
// We've exhausted all scenarios,
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
-5
View File
@@ -326,11 +326,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
this.setEventMetadata(event);
if (!this.initialEventsFetched && !toStartOfTimeline && event.getId() === this.id) {
// We're loading the thread organically
this.initialEventsFetched = true;
}
const lastReply = this.lastReply();
const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
+4
View File
@@ -217,6 +217,10 @@ export class TypedEventEmitter<
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public removeAllListeners(event?: Events | EventEmitterEvents): this {
// EventEmitter::removeAllListeners uses `arguments.length` to determine undefined case
if (event === undefined) {
return super.removeAllListeners();
}
return super.removeAllListeners(event);
}
+129 -79
View File
@@ -14,13 +14,24 @@ 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 { IDelegatedAuthConfig } from "../client";
import { Method } from "../http-api";
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
import { logger } from "../logger";
import { randomString } from "../randomstring";
import { OidcError } from "./error";
import { validateIdToken, ValidatedIssuerConfig } from "./validate";
import {
validateIdToken,
ValidatedIssuerMetadata,
validateStoredUserState,
UserState,
BearerTokenResponse,
validateBearerTokenResponse,
} from "./validate";
// reexport for backwards compatibility
export type { BearerTokenResponse };
/**
* Authorization parameters which are used in the authentication request of an OIDC auth code flow.
@@ -35,6 +46,11 @@ export type AuthorizationParams = {
nonce: string;
};
/**
* @experimental
* 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}`;
@@ -74,6 +90,7 @@ export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: stri
});
/**
* @deprecated use generateOidcAuthorizationUrl
* Generate a URL to attempt authorization with the OP
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
* @param authorizationUrl - endpoint to attempt authorization with the OP
@@ -102,36 +119,48 @@ export const generateAuthorizationUrl = async (
};
/**
* The expected response type from the token endpoint during authorization code flow
* Normalized to always use capitalized 'Bearer' for token_type
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
* @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
* @returns a Promise with the url as a string
*/
export type BearerTokenResponse = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
export const generateOidcAuthorizationUrl = async ({
metadata,
redirectUri,
clientId,
homeserverUrl,
identityServerUrl,
nonce,
}: {
clientId: string;
metadata: ValidatedIssuerMetadata;
homeserverUrl: string;
identityServerUrl?: string;
redirectUri: string;
nonce: string;
}): Promise<string> => {
const scope = await generateScope();
const oidcClient = new OidcClient({
...metadata,
client_id: clientId,
redirect_uri: redirectUri,
authority: metadata.issuer,
response_mode: "query",
response_type: "code",
scope,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
});
const userState: UserState = { homeserverUrl, nonce, identityServerUrl };
const request = await oidcClient.createSigninRequest({
state: userState,
nonce,
});
/**
* Expected response type from the token endpoint during authorization code flow
* as it comes over the wire.
* Should be normalized to use capital case 'Bearer' for token_type property
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
type WireBearerTokenResponse = BearerTokenResponse & {
token_type: "Bearer" | "bearer";
return request.url;
};
const isResponseObject = (response: unknown): response is Record<string, unknown> =>
!!response && typeof response === "object";
/**
* Normalize token_type to use capital case to make consuming the token response easier
* token_type is case insensitive, and it is spec-compliant for OPs to return token_type: "bearer"
@@ -141,21 +170,18 @@ const isResponseObject = (response: unknown): response is Record<string, unknown
* @param response - validated token response
* @returns response with token_type set to 'Bearer'
*/
const normalizeBearerTokenResponseTokenType = (response: WireBearerTokenResponse): BearerTokenResponse => ({
...response,
token_type: "Bearer",
});
const isValidBearerTokenResponse = (response: unknown): response is WireBearerTokenResponse =>
isResponseObject(response) &&
typeof response["token_type"] === "string" &&
// token_type is case insensitive, some OPs return `token_type: "bearer"`
response["token_type"].toLowerCase() === "bearer" &&
typeof response["access_token"] === "string" &&
(!("refresh_token" in response) || typeof response["refresh_token"] === "string") &&
(!("expires_in" in response) || typeof response["expires_in"] === "number");
const normalizeBearerTokenResponseTokenType = (response: SigninResponse): BearerTokenResponse =>
({
id_token: response.id_token,
scope: response.scope,
expires_at: response.expires_at,
refresh_token: response.refresh_token,
access_token: response.access_token,
token_type: "Bearer",
} as BearerTokenResponse);
/**
* @experimental
* Attempt to exchange authorization code for bearer token.
*
* Takes the authorization code returned by the OpenID Provider via the authorization URL, and makes a
@@ -168,47 +194,71 @@ const isValidBearerTokenResponse = (response: unknown): response is WireBearerTo
*/
export const completeAuthorizationCodeGrant = async (
code: string,
{
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
}: {
clientId: string;
codeVerifier: string;
redirectUri: string;
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig;
nonce: string;
},
): Promise<BearerTokenResponse> => {
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("client_id", clientId);
params.append("code_verifier", codeVerifier);
params.append("redirect_uri", redirectUri);
params.append("code", code);
const metadata = params.toString();
state: string,
): Promise<{
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
tokenResponse: BearerTokenResponse;
homeserverUrl: string;
identityServerUrl?: string;
}> => {
/**
* Element Web strips and changes the url on starting the app
* Use the code and state from query params to rebuild a url
* so that oidc-client can parse it
*/
const reconstructedUrl = new URL(window.location.origin);
reconstructedUrl.searchParams.append("code", code);
reconstructedUrl.searchParams.append("state", state);
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
// set oidc-client to use our logger
Log.setLogger(logger);
try {
const response = new SigninResponse(reconstructedUrl.searchParams);
const response = await fetch(delegatedAuthConfig.tokenEndpoint, {
method: Method.Post,
headers,
body: metadata,
});
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
if (response.status >= 400) {
// retrieve the state we put in storage at the start of oidc auth flow
const stateString = await stateStore.get(response.state!);
if (!stateString) {
throw new Error(OidcError.MissingOrInvalidStoredState);
}
// hydrate the sign in state and create a client
// the stored sign in state includes oidc configuration we set at the start of the oidc login flow
const signInState = SigninState.fromStorageString(stateString);
const client = new OidcClient({ ...signInState, stateStore });
// validate the code and state, and attempt to swap the code for tokens
const signinResponse = await client.processSigninResponse(reconstructedUrl.href);
// extra values we stored at the start of the login flow
// used to complete login in the client
const userState = signinResponse.userState;
validateStoredUserState(userState);
// throws when response is invalid
validateBearerTokenResponse(signinResponse);
// throws when token is invalid
validateIdToken(signinResponse.id_token, client.settings.authority, client.settings.client_id, userState.nonce);
const normalizedTokenResponse = normalizeBearerTokenResponseTokenType(signinResponse);
return {
oidcClientSettings: {
clientId: client.settings.client_id,
issuer: client.settings.authority,
},
tokenResponse: normalizedTokenResponse,
homeserverUrl: userState.homeserverUrl,
identityServerUrl: userState.identityServerUrl,
};
} catch (error) {
logger.error("Oidc login failed", error);
const errorType = (error as Error).message;
// rethrow errors that we recognise
if (Object.values(OidcError).includes(errorType as any)) {
throw error;
}
throw new Error(OidcError.CodeExchangeFailed);
}
const token = await response.json();
if (isValidBearerTokenResponse(token)) {
// throws when token is invalid
validateIdToken(token.id_token, delegatedAuthConfig.issuer, clientId, nonce);
return normalizeBearerTokenResponseTokenType(token);
}
throw new Error(OidcError.InvalidBearerTokenResponse);
};
+61
View File
@@ -0,0 +1,61 @@
/*
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 { MetadataService, OidcClientSettingsStore, SigningKey } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { isValidatedIssuerMetadata, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate";
/**
* @experimental
* Discover and validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, validated metadata is returned
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable
*/
export const discoverAndValidateAuthenticationConfig = async (
authenticationConfig?: IDelegatedAuthConfig,
): Promise<
IDelegatedAuthConfig & {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
> => {
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authenticationConfig);
// create a temporary settings store so we can use metadata service for discovery
const settings = new OidcClientSettingsStore({
authority: homeserverAuthenticationConfig.issuer,
redirect_uri: "", // Not known yet, this is here to make the type checker happy
client_id: "", // Not known yet, this is here to make the type checker happy
});
const metadataService = new MetadataService(settings);
const metadata = await metadataService.getMetadata();
const signingKeys = (await metadataService.getSigningKeys()) ?? undefined;
isValidatedIssuerMetadata(metadata);
return {
...homeserverAuthenticationConfig,
metadata,
signingKeys,
};
};
+1
View File
@@ -25,4 +25,5 @@ export enum OidcError {
CodeExchangeFailed = "Failed to exchange code for token",
InvalidBearerTokenResponse = "Invalid bearer token response",
InvalidIdToken = "Invalid ID token",
MissingOrInvalidStoredState = "State required to finish logging in is not found in storage.",
}
+114 -7
View File
@@ -15,8 +15,9 @@ limitations under the License.
*/
import jwtDecode from "jwt-decode";
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client";
import { IDelegatedAuthConfig } from "../client";
import { logger } from "../logger";
import { OidcError } from "./error";
@@ -39,9 +40,7 @@ export type ValidatedIssuerConfig = {
* @returns config - when present and valid
* @throws when config is not found or invalid
*/
export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): IDelegatedAuthConfig => {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown);
export const validateWellKnownAuthentication = (authentication?: IDelegatedAuthConfig): IDelegatedAuthConfig => {
if (!authentication) {
throw new Error(OidcError.NotSupported);
}
@@ -63,14 +62,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
if (!wellKnown[key] || !optionalStringProperty(wellKnown, key)) {
logger.error(`OIDC issuer configuration: ${key} is invalid`);
logger.error(`Missing or invalid property: ${key}`);
return false;
}
return true;
};
const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
if (!!wellKnown[key] && typeof wellKnown[key] !== "string") {
logger.error(`OIDC issuer configuration: ${key} is invalid`);
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
@@ -78,7 +77,7 @@ const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string)
const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, value: any): boolean => {
const array = wellKnown[key];
if (!array || !Array.isArray(array) || !array.includes(value)) {
logger.error(`OIDC issuer configuration: ${key} is invalid. ${value} is required.`);
logger.error(`Invalid property: ${key}. ${value} is required.`);
return false;
}
return true;
@@ -101,6 +100,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
const isInvalid = [
requiredStringProperty(wellKnown, "authorization_endpoint"),
requiredStringProperty(wellKnown, "token_endpoint"),
requiredStringProperty(wellKnown, "revocation_endpoint"),
optionalStringProperty(wellKnown, "registration_endpoint"),
requiredArrayValue(wellKnown, "response_types_supported", "code"),
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
@@ -119,6 +119,36 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
throw new Error(OidcError.OpSupport);
};
/**
* Metadata from OIDC authority discovery
* With validated properties required in type
*/
export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
Pick<
OidcMetadata,
| "issuer"
| "authorization_endpoint"
| "token_endpoint"
| "registration_endpoint"
| "revocation_endpoint"
| "response_types_supported"
| "grant_types_supported"
| "code_challenge_methods_supported"
>;
/**
* Wraps validateOIDCIssuerWellKnown in a type assertion
* that asserts expected properties are present
* (Typescript assertions cannot be arrow functions)
* @param metadata - issuer openid-configuration response
* @throws when metadata validation fails
*/
export function isValidatedIssuerMetadata(
metadata: Partial<OidcMetadata>,
): asserts metadata is ValidatedIssuerMetadata {
validateOIDCIssuerWellKnown(metadata);
}
/**
* Standard JWT claims.
*
@@ -199,3 +229,80 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
throw new Error(OidcError.InvalidIdToken);
}
};
/**
* State we ask OidcClient to store when starting oidc authorization flow (in `generateOidcAuthorizationUrl`)
* so that we can access it on return from the OP and complete login
*/
export type UserState = {
/**
* Remember which server we were trying to login to
*/
homeserverUrl: string;
identityServerUrl?: string;
/**
* Used to validate id token
*/
nonce: string;
};
/**
* Validate stored user state exists and is valid
* @param userState - userState returned by oidcClient.processSigninResponse
* @throws when userState is invalid
*/
export function validateStoredUserState(userState: unknown): asserts userState is UserState {
if (!isRecord(userState)) {
logger.error("Stored user state not found");
throw new Error(OidcError.MissingOrInvalidStoredState);
}
const isInvalid = [
requiredStringProperty(userState, "homeserverUrl"),
requiredStringProperty(userState, "nonce"),
optionalStringProperty(userState, "identityServerUrl"),
].some((isValid) => !isValid);
if (isInvalid) {
throw new Error(OidcError.MissingOrInvalidStoredState);
}
}
/**
* The expected response type from the token endpoint during authorization code flow
* Normalized to always use capitalized 'Bearer' for token_type
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
export type BearerTokenResponse = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
// from oidc-client-ts
expires_at?: number;
id_token?: string;
};
/**
* Make required properties required in type
*/
type ValidSignInResponse = SigninResponse &
BearerTokenResponse & {
token_type: "Bearer" | "bearer";
};
const isValidBearerTokenResponse = (response: unknown): response is ValidSignInResponse =>
isRecord(response) &&
requiredStringProperty(response, "token_type") &&
// token_type is case insensitive, some OPs return `token_type: "bearer"`
(response["token_type"] as string).toLowerCase() === "bearer" &&
requiredStringProperty(response, "access_token") &&
requiredStringProperty(response, "refresh_token") &&
(!("expires_in" in response) || typeof response["expires_in"] === "number");
export function validateBearerTokenResponse(response: unknown): asserts response is ValidSignInResponse {
if (!isValidBearerTokenResponse(response)) {
throw new Error(OidcError.InvalidBearerTokenResponse);
}
}
+3 -3
View File
@@ -78,7 +78,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
conditions: [
{
kind: ConditionKind.EventPropertyContains,
key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids",
key: "content.m\\.mentions.user_ids",
value: "", // The user ID is dynamically added in rewriteDefaultRules.
},
],
@@ -91,7 +91,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
conditions: [
{
kind: ConditionKind.EventPropertyIs,
key: "content.org\\.matrix\\.msc3952\\.mentions.room",
key: "content.m\\.mentions.room",
value: true,
},
{
@@ -724,7 +724,7 @@ export class PushProcessor {
// Disable the deprecated mentions push rules if the new mentions property exists.
if (
this.client.supportsIntentionalMentions() &&
ev.getContent()["org.matrix.msc3952.mentions"] !== undefined &&
ev.getContent()["m.mentions"] !== undefined &&
(rule.rule_id === RuleId.ContainsUserName ||
rule.rule_id === RuleId.ContainsDisplayName ||
rule.rule_id === RuleId.AtRoomNotification)
+10 -3
View File
@@ -17,7 +17,12 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import { IMSC3882GetLoginTokenCapability, MatrixClient, UNSTABLE_MSC3882_CAPABILITY } from "../client";
import {
ICrossSigningKey,
IMSC3882GetLoginTokenCapability,
MatrixClient,
UNSTABLE_MSC3882_CAPABILITY,
} from "../client";
import { CrossSigningInfo } from "../crypto/CrossSigning";
import { DeviceInfo } from "../crypto/deviceinfo";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
@@ -178,7 +183,9 @@ export class MSC3906Rendezvous {
return deviceId;
}
private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise<CrossSigningInfo | DeviceInfo> {
private async verifyAndCrossSignDevice(
deviceInfo: DeviceInfo,
): Promise<CrossSigningInfo | DeviceInfo | ICrossSigningKey | undefined> {
if (!this.client.crypto) {
throw new Error("Crypto not available on client");
}
@@ -223,7 +230,7 @@ export class MSC3906Rendezvous {
*/
public async verifyNewDeviceOnExistingDevice(
timeout = 10 * 1000,
): Promise<DeviceInfo | CrossSigningInfo | undefined> {
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
if (!this.newDeviceId) {
throw new Error("No new device to sign");
}
+34 -3
View File
@@ -14,19 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-js";
import { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-wasm";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { BootstrapCrossSigningOpts } from "../crypto-api";
import { logger } from "../logger";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { UIAuthCallback } from "../interactive-auth";
import { ServerSideSecretStorage } from "../secret-storage";
/** Manages the cross-signing keys for our own user.
*
* @internal
*/
export class CrossSigningIdentity {
public constructor(
private readonly olmMachine: OlmMachine,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
private readonly secretStorage: ServerSideSecretStorage,
/** Called if the cross signing keys are imported from the secret storage */
private readonly onCrossSigningKeysImport: () => void,
) {}
/**
@@ -39,7 +46,15 @@ export class CrossSigningIdentity {
}
const olmDeviceStatus: CrossSigningStatus = await this.olmMachine.crossSigningStatus();
const privateKeysInSecretStorage = false; // TODO
// Try to fetch cross signing keys from the secret storage
const masterKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.master");
const selfSigningKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.self_signing");
const userSigningKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.user_signing");
const privateKeysInSecretStorage = Boolean(
masterKeyFromSecretStorage && selfSigningKeyFromSecretStorage && userSigningKeyFromSecretStorage,
);
const olmDeviceHasKeys =
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
@@ -65,7 +80,23 @@ export class CrossSigningIdentity {
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
"in secret storage, reading storage and caching locally",
);
throw new Error("TODO");
await this.olmMachine.importCrossSigningKeys(
masterKeyFromSecretStorage,
selfSigningKeyFromSecretStorage,
userSigningKeyFromSecretStorage,
);
// Get the current device
const device: RustSdkCryptoJs.Device = await this.olmMachine.getDevice(
this.olmMachine.userId,
this.olmMachine.deviceId,
);
// Sign the device with our cross-signing key and upload the signature
const request: RustSdkCryptoJs.SignatureUploadRequest = await device.verify();
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
this.onCrossSigningKeysImport();
}
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
+3 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js";
import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
@@ -22,6 +22,8 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
* KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
*
* We have one of these per `RustCrypto` (and hence per `MatrixClient`).
*
* @internal
*/
export class KeyClaimManager {
private currentClaimPromise: Promise<void>;
+17 -6
View File
@@ -24,7 +24,7 @@ import {
SignatureUploadRequest,
ToDeviceRequest,
SigningKeysUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
@@ -34,6 +34,8 @@ import { UIAResponse } from "../@types/uia";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
*
* @internal
*/
export interface OutgoingRequest {
readonly id: string | undefined;
@@ -49,6 +51,8 @@ export interface OutgoingRequest {
* * holding the reference to the `MatrixHttpApi`
* * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them
* * sending the results of such requests back to the rust backend.
*
* @internal
*/
export class OutgoingRequestProcessor {
public constructor(
@@ -71,7 +75,12 @@ export class OutgoingRequestProcessor {
} else if (msg instanceof SignatureUploadRequest) {
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
} else if (msg instanceof KeysBackupRequest) {
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
resp = await this.rawJsonRequest(
Method.Put,
"/_matrix/client/v3/room_keys/keys",
{ version: msg.version },
msg.body,
);
} else if (msg instanceof ToDeviceRequest) {
const path =
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
@@ -79,7 +88,7 @@ export class OutgoingRequestProcessor {
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else if (msg instanceof RoomMessageRequest) {
const path =
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
`/_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) {
@@ -112,11 +121,13 @@ export class OutgoingRequestProcessor {
}
const parsedBody = JSON.parse(body);
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
const newBody = {
const makeRequest = async (auth: IAuthDict | null): Promise<UIAResponse<T>> => {
const newBody: Record<string, any> = {
...parsedBody,
auth,
};
if (auth !== null) {
newBody.auth = auth;
}
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
return JSON.parse(resp) as T;
};
+3 -1
View File
@@ -14,7 +14,7 @@ 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-js";
import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
import { EventType } from "../@types/event";
import { IContent, MatrixEvent } from "../models/event";
@@ -26,6 +26,8 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
/**
* RoomEncryptor: responsible for encrypting messages to a given room
*
* @internal
*/
export class RoomEncryptor {
private readonly prefixedLogger: PrefixedLogger;
+294
View File
@@ -0,0 +1,294 @@
/*
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 { OlmMachine, SignatureVerification } from "@matrix-org/matrix-sdk-crypto-wasm";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { BackupTrustInfo, Curve25519AuthData, KeyBackupCheck, KeyBackupInfo } from "../crypto-api/keybackup";
import { logger } from "../logger";
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
import { CryptoEvent } from "../crypto";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { sleep } from "../utils";
/**
* @internal
*/
export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap> {
/** Have we checked if there is a backup on the server which we can use */
private checkedForBackup = false;
private activeBackupVersion: string | null = null;
private stopped = false;
/** whether {@link backupKeysLoop} is currently running */
private backupKeysLoopRunning = false;
public constructor(
private readonly olmMachine: OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
) {
super();
}
/**
* Tells the RustBackupManager to stop.
* The RustBackupManager is scheduling background uploads of keys to the backup, this
* call allows to cancel the process when the client is stoppped.
*/
public stop(): void {
this.stopped = true;
}
/**
* Get the backup version we are currently backing up to, if any
*/
public async getActiveBackupVersion(): Promise<string | null> {
if (!this.olmMachine.isBackupEnabled()) return null;
return this.activeBackupVersion;
}
/**
* Determine if a key backup can be trusted.
*
* @param info - key backup info dict from {@link MatrixClient#getKeyBackupVersion}.
*/
public async isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo> {
const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info);
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
const pubKeyForSavedPrivateKey = backupKeys?.decryptionKey?.megolmV1PublicKey;
const backupMatchesSavedPrivateKey =
info.algorithm === pubKeyForSavedPrivateKey?.algorithm &&
(info.auth_data as Curve25519AuthData)?.public_key === pubKeyForSavedPrivateKey.publicKeyBase64;
return {
matchesDecryptionKey: backupMatchesSavedPrivateKey,
trusted: signatureVerification.trusted(),
};
}
/**
* Re-check the key backup and enable/disable it as appropriate.
*
* @param force - whether we should force a re-check even if one has already happened.
*/
public checkKeyBackupAndEnable(force: boolean): Promise<KeyBackupCheck | null> {
if (!force && this.checkedForBackup) {
return Promise.resolve(null);
}
// make sure there is only one check going on at a time
if (!this.keyBackupCheckInProgress) {
this.keyBackupCheckInProgress = this.doCheckKeyBackup().finally(() => {
this.keyBackupCheckInProgress = null;
});
}
return this.keyBackupCheckInProgress;
}
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
/** Helper for `checkKeyBackup` */
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
logger.log("Checking key backup status...");
let backupInfo: KeyBackupInfo | null = null;
try {
backupInfo = await this.requestKeyBackupVersion();
} catch (e) {
logger.warn("Error checking for active key backup", e);
return null;
}
this.checkedForBackup = true;
if (backupInfo && !backupInfo.version) {
logger.warn("active backup lacks a useful 'version'; ignoring it");
}
const activeVersion = await this.getActiveBackupVersion();
if (!backupInfo) {
if (activeVersion !== null) {
logger.log("No key backup present on server: disabling key backup");
await this.disableKeyBackup();
} else {
logger.log("No key backup present on server: not enabling key backup");
}
return null;
}
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (!trustInfo.trusted) {
if (activeVersion !== null) {
logger.log("Key backup present on server but not trusted: disabling key backup");
await this.disableKeyBackup();
} else {
logger.log("Key backup present on server but not trusted: not enabling key backup");
}
} else {
if (activeVersion === null) {
logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`);
await this.enableKeyBackup(backupInfo);
} else if (activeVersion !== backupInfo.version) {
logger.log(`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`);
// This will remove any pending backup request, remove the backup key and reset the backup state of each room key we have.
await this.disableKeyBackup();
// Enabling will now trigger re-upload of all the keys
await this.enableKeyBackup(backupInfo);
} else {
logger.log(`Backup version ${backupInfo.version} still current`);
}
}
return { backupInfo, trustInfo };
}
private async enableKeyBackup(backupInfo: KeyBackupInfo): Promise<void> {
// we know for certain it must be a Curve25519 key, because we have verified it and only Curve25519
// keys can be verified.
//
// we also checked it has a valid `version`.
await this.olmMachine.enableBackupV1(
(backupInfo.auth_data as Curve25519AuthData).public_key,
backupInfo.version!,
);
this.activeBackupVersion = backupInfo.version!;
this.emit(CryptoEvent.KeyBackupStatus, true);
this.backupKeysLoop();
}
/**
* Restart the backup key loop if there is an active trusted backup.
* Doesn't try to check the backup server side. To be called when a new
* megolm key is known locally.
*/
public async maybeUploadKey(): Promise<void> {
if (this.activeBackupVersion != null) {
this.backupKeysLoop();
}
}
private async disableKeyBackup(): Promise<void> {
await this.olmMachine.disableBackup();
this.activeBackupVersion = null;
this.emit(CryptoEvent.KeyBackupStatus, false);
}
private async backupKeysLoop(maxDelay = 10000): Promise<void> {
if (this.backupKeysLoopRunning) {
logger.log(`Backup loop already running`);
return;
}
this.backupKeysLoopRunning = true;
logger.log(`Starting loop for ${this.activeBackupVersion}.`);
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay);
try {
let numFailures = 0; // number of consecutive network failures for exponential backoff
while (!this.stopped) {
// Get a batch of room keys to upload
const request: RustSdkCryptoJs.KeysBackupRequest | null = await this.olmMachine.backupRoomKeys();
if (!request || this.stopped || !this.activeBackupVersion) {
logger.log(`Ending loop for ${this.activeBackupVersion}.`);
return;
}
try {
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
numFailures = 0;
const keyCount: RustSdkCryptoJs.RoomKeyCounts = await this.olmMachine.roomKeyCounts();
const remaining = keyCount.total - keyCount.backedUp;
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
} catch (err) {
numFailures++;
logger.error("Error processing backup request for rust crypto-sdk", err);
if (err instanceof MatrixError) {
const errCode = err.data.errcode;
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
await this.disableKeyBackup();
this.emit(CryptoEvent.KeyBackupFailed, err.data.errcode!);
// There was an active backup and we are out of sync with the server
// force a check server side
this.backupKeysLoopRunning = false;
this.checkKeyBackupAndEnable(true);
return;
} else if (errCode == "M_LIMIT_EXCEEDED") {
// wait for that and then continue?
const waitTime = err.data.retry_after_ms;
if (waitTime > 0) {
sleep(waitTime);
continue;
} // else go to the normal backoff
}
}
// Some other errors (mx, network, or CORS or invalid urls?) anyhow backoff
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
}
} finally {
this.backupKeysLoopRunning = false;
}
}
/**
* Get information about the current key backup from the server
*
* @returns Information object from API or null if there is no active backup.
*/
private async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
try {
return await this.http.authedRequest<KeyBackupInfo>(
Method.Get,
"/room_keys/version",
undefined,
undefined,
{
prefix: ClientPrefix.V3,
},
);
} catch (e) {
if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
return null;
} else {
throw e;
}
}
}
}
export type RustBackupCryptoEvents =
| CryptoEvent.KeyBackupStatus
| CryptoEvent.KeyBackupSessionsRemaining
| CryptoEvent.KeyBackupFailed;
export type RustBackupCryptoEventMap = {
[CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void;
[CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void;
[CryptoEvent.KeyBackupFailed]: (errCode: string) => void;
};

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