Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a491508543 | |||
| 0abba3e626 | |||
| d669ddfab2 | |||
| 9caa38d386 | |||
| 1c16b5cae6 | |||
| cb375e1351 | |||
| 5e542b3869 | |||
| c9435af637 | |||
| 40168d4419 | |||
| 6d118008b6 | |||
| 1503acb30a | |||
| 1b8507c060 | |||
| d95b5ab27a | |||
| 658e7b1be3 | |||
| 95110eb889 | |||
| 9fbcef556e | |||
| b68ad00394 | |||
| 6836720e1e | |||
| 6f517478df | |||
| 35ba4074de | |||
| c7827d971c | |||
| f963ca5562 | |||
| 8c30b0d12c | |||
| 5d4334ba4c | |||
| 7e691bf700 | |||
| 0700e86f58 | |||
| 6c307d4c63 | |||
| 88ec0e3e17 | |||
| 015e9a5be7 | |||
| 2918d686ae | |||
| 327c18ddc1 | |||
| 8cdd8e882b | |||
| 76e0d5a896 | |||
| 836238c3ba | |||
| 014b29b303 | |||
| 74160806c0 | |||
| 8e0ef98bcc | |||
| d7831f9e5b | |||
| 989c5a3dda | |||
| 0778c4e01e | |||
| c65e329101 | |||
| 5ddd453699 | |||
| 42d982dd69 | |||
| f406ffd3dd | |||
| dec4650d3d | |||
| 4c00b41046 | |||
| a1845ba0ff | |||
| fb9e258468 | |||
| 974723ceef | |||
| 5788d9744b | |||
| 65cbbaaf01 | |||
| c5245a887b | |||
| 321679fd63 | |||
| 15c679b29e | |||
| 85ba069117 | |||
| 9b8dcf53ed | |||
| 324af3ee67 | |||
| ec6c0946d4 | |||
| e5f480b032 | |||
| 6bf4ed8672 | |||
| c18d691ef5 | |||
| 97cf73bc52 | |||
| aa25103665 | |||
| 858db67778 | |||
| e230abee45 | |||
| 8c16d69f3c | |||
| 55b9116c99 | |||
| 3a5d66057e | |||
| 3f7af189e4 | |||
| 16ddcb0ed0 | |||
| 9e35b8dd0a | |||
| bed787b749 | |||
| d260b8be56 | |||
| 97991dad02 | |||
| b8c19c47ab | |||
| 1476ffbd15 | |||
| 62f0a65472 | |||
| 2ef7ae7661 | |||
| 61c0a49971 | |||
| 2172f28888 | |||
| 2e9b34e0c3 | |||
| 5a782b7377 | |||
| 54bc807056 | |||
| 9e07710d80 | |||
| e9ed91d800 | |||
| 88ba4fad71 | |||
| 21b3471453 | |||
| 0ada9803ab | |||
| 1744f0e97b | |||
| fd0c4a7f56 | |||
| 615f7f9e72 | |||
| 77259e81c9 | |||
| 2193cd9d1c | |||
| 6d28154dcd | |||
| 83d447adfe | |||
| 73c9f4e322 | |||
| e6fa4cdb3c | |||
| a04653a72c | |||
| 5f9341f39c | |||
| 906946c419 | |||
| 4397b9d640 | |||
| 90da2cf439 | |||
| 6edd45787b | |||
| 84444ec11e | |||
| 0e95df5dba | |||
| 29b815b678 | |||
| 0cf056958b | |||
| 79d4113a6b | |||
| 8a80886358 | |||
| de7959de6c | |||
| 533c21a515 | |||
| 6b018b6927 | |||
| 38c3abb364 | |||
| a47f319665 | |||
| ecef9fd755 | |||
| 7dffd8ffd3 | |||
| 66492e7ba8 | |||
| 43b2404865 | |||
| fed9910fa1 | |||
| f77662406c | |||
| 8cc0cf1a70 | |||
| dfa2429094 | |||
| 3e2460707c | |||
| 706c084fa7 | |||
| eb7faa6c07 | |||
| d45a0b894a | |||
| 102739e0fb | |||
| 0d7e4a0fa5 | |||
| d4628e78d4 | |||
| 0b193f4665 | |||
| 8ef2e848b9 | |||
| d92936fba5 | |||
| f005984df3 | |||
| 13fec49e74 | |||
| 008294cfc6 | |||
| b05f933d83 | |||
| b186d79dde | |||
| e82b5fe1db | |||
| 9602aa88ea | |||
| 0fb3dc1b13 | |||
| aeede332be | |||
| b052950a19 | |||
| 1cb5fff5a1 | |||
| 01226e41d9 | |||
| e3919fd93b | |||
| 3b88ea19b7 | |||
| dcf26f3e48 | |||
| 3385adf5f6 | |||
| 9db6ce107a | |||
| d5b22e1deb | |||
| a5e606a1e7 | |||
| f2471b6dbd | |||
| dcf71e0c8f | |||
| 77267e393c | |||
| 1fdc0af5b7 | |||
| d2b782a2f5 | |||
| 5df4ebaada | |||
| e68a1471c1 | |||
| e42dd74426 | |||
| 2751e191d3 | |||
| b5b86bf1b5 | |||
| 4990bf5ca0 | |||
| b8fa030d5d | |||
| b606d1e54b | |||
| cd7c519dc4 | |||
| 30dd28960c | |||
| 5b635df08d | |||
| 592c497902 | |||
| 8e3f2f3262 | |||
| 5751df1288 | |||
| 40a71101e2 | |||
| 3f095caf2d | |||
| 12a94bdd94 | |||
| 1c1ac137d3 | |||
| 89cabc4912 | |||
| 5be4548b3d | |||
| 09de76bd43 | |||
| 3a694f4998 | |||
| 3a8a1389f5 | |||
| c271e1533a | |||
| 722debe8f9 | |||
| 5165899e82 | |||
| 1828826661 | |||
| 24cee68fa2 | |||
| e645af1fc5 | |||
| de64779c27 | |||
| acbcb4658a | |||
| 815484b543 | |||
| 5a3d1a2a67 | |||
| 18626169e4 | |||
| e4a9f958a0 | |||
| ff29de743c | |||
| 5a68861418 | |||
| e285932776 | |||
| 2af0706b16 | |||
| 4382d2a425 | |||
| 9de4a057df | |||
| b703d4a2cc | |||
| d1dec4cd08 | |||
| 326a13bcfe | |||
| e8fb47fdca | |||
| bd66e3859d | |||
| 96e484a3fe | |||
| 3e646bdfa0 | |||
| 48c4127035 | |||
| f16a6bc654 | |||
| f884c78579 | |||
| 3c59476cf7 | |||
| c8f6c4dd0d | |||
| e8c89e9977 | |||
| df78d7cf67 | |||
| 80fec814a2 | |||
| 8b9672ba43 |
@@ -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": [
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
/package.json @matrix-org/element-web-app-team
|
||||
/yarn.lock @matrix-org/element-web-app-team
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/src/matrixrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/webrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/matrixrtc @matrix-org/element-call-reviewers
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.73.1
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.79.0
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
@@ -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.73.1
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.79.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2
|
||||
uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 # v2
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
@@ -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
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: _docs
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
|
||||
@@ -18,10 +18,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [16, 18, latest]
|
||||
node: [18, latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
@@ -50,6 +50,9 @@ jobs:
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
|
||||
# tell jest to use coloured output
|
||||
FORCE_COLOR: true
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
|
||||
+153
@@ -1,3 +1,156 @@
|
||||
Changes in [28.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.2.0) (2023-09-26)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Implement `getEncryptionInfoForEvent` and deprecate `getEventEncryptionInfo` ([\#3693](https://github.com/matrix-org/matrix-js-sdk/pull/3693)).
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Delete knocked room when knock membership changes ([\#3729](https://github.com/matrix-org/matrix-js-sdk/pull/3729)). Contributed by @maheichyk.
|
||||
* Introduce MatrixRTCSession lower level group call primitive ([\#3663](https://github.com/matrix-org/matrix-js-sdk/pull/3663)).
|
||||
* Sync knock rooms ([\#3703](https://github.com/matrix-org/matrix-js-sdk/pull/3703)). Contributed by @maheichyk.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Dont access indexed db when undefined ([\#3707](https://github.com/matrix-org/matrix-js-sdk/pull/3707)). Contributed by @finsterwalder.
|
||||
* Don't reset unread count when adding a synthetic receipt ([\#3706](https://github.com/matrix-org/matrix-js-sdk/pull/3706)). Fixes #3684. Contributed by @andybalaam.
|
||||
|
||||
Changes in [28.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.1.0) (2023-09-12)
|
||||
============================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate `MatrixClient.checkUserTrust` ([\#3691](https://github.com/matrix-org/matrix-js-sdk/pull/3691)).
|
||||
* Deprecate `MatrixClient.{prepare,create}KeyBackupVersion` in favour of new `CryptoApi.resetKeyBackup` API ([\#3689](https://github.com/matrix-org/matrix-js-sdk/pull/3689)).
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Allow calls without ICE/TURN/STUN servers ([\#3695](https://github.com/matrix-org/matrix-js-sdk/pull/3695)).
|
||||
* Emit summary update event ([\#3687](https://github.com/matrix-org/matrix-js-sdk/pull/3687)). Fixes vector-im/element-web#26033.
|
||||
* ElementR: Update `CryptoApi.userHasCrossSigningKeys` ([\#3646](https://github.com/matrix-org/matrix-js-sdk/pull/3646)). Contributed by @florianduros.
|
||||
* Add `join_rule` field to /publicRooms response ([\#3673](https://github.com/matrix-org/matrix-js-sdk/pull/3673)). Contributed by @charlynguyen.
|
||||
* Use sender instead of content.creator field on m.room.create events ([\#3675](https://github.com/matrix-org/matrix-js-sdk/pull/3675)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Provide better error for ICE Server SyntaxError ([\#3694](https://github.com/matrix-org/matrix-js-sdk/pull/3694)). Fixes vector-im/element-web#21804.
|
||||
* Legacy crypto: re-check key backup after `bootstrapSecretStorage` ([\#3692](https://github.com/matrix-org/matrix-js-sdk/pull/3692)). Fixes vector-im/element-web#26115.
|
||||
|
||||
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)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)).
|
||||
* ElementR: Add `CryptoApi#bootstrapSecretStorage` ([\#3483](https://github.com/matrix-org/matrix-js-sdk/pull/3483)). Contributed by @florianduros.
|
||||
* Deprecate `MatrixClient.findVerificationRequestDMInProgress`, `MatrixClient.getVerificationRequestsToDeviceInProgress`, and `MatrixClient.requestVerification`, in favour of methods in `CryptoApi`. ([\#3474](https://github.com/matrix-org/matrix-js-sdk/pull/3474)).
|
||||
* Introduce a new `Crypto.VerificationRequest` interface, and deprecate direct access to the old `VerificationRequest` class. Also deprecate some related classes that were exported from `src/crypto/verification/request/VerificationRequest` ([\#3449](https://github.com/matrix-org/matrix-js-sdk/pull/3449)).
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: navigate to authorization endpoint ([\#3499](https://github.com/matrix-org/matrix-js-sdk/pull/3499)). Contributed by @kerryarchibald.
|
||||
* Support for interactive device verification in Element-R. ([\#3505](https://github.com/matrix-org/matrix-js-sdk/pull/3505)).
|
||||
* Support for interactive device verification in Element-R. ([\#3508](https://github.com/matrix-org/matrix-js-sdk/pull/3508)).
|
||||
* Support for interactive device verification in Element-R. ([\#3490](https://github.com/matrix-org/matrix-js-sdk/pull/3490)). Fixes vector-im/element-web#25316.
|
||||
* Element-R: Store cross signing keys in secret storage ([\#3498](https://github.com/matrix-org/matrix-js-sdk/pull/3498)). Contributed by @florianduros.
|
||||
* OIDC: add dynamic client registration util function ([\#3481](https://github.com/matrix-org/matrix-js-sdk/pull/3481)). Contributed by @kerryarchibald.
|
||||
* Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room ([\#3493](https://github.com/matrix-org/matrix-js-sdk/pull/3493)).
|
||||
* ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation ([\#3472](https://github.com/matrix-org/matrix-js-sdk/pull/3472)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Aggregate relations regardless of whether event fits into the timeline ([\#3496](https://github.com/matrix-org/matrix-js-sdk/pull/3496)). Fixes vector-im/element-web#25596.
|
||||
* Fix bug where switching media caused media in subsequent calls to fail ([\#3489](https://github.com/matrix-org/matrix-js-sdk/pull/3489)).
|
||||
* Fix: remove polls from room state on redaction ([\#3475](https://github.com/matrix-org/matrix-js-sdk/pull/3475)). Fixes vector-im/element-web#25573. Contributed by @kerryarchibald.
|
||||
* Fix export type `GeneratedSecretStorageKey` ([\#3479](https://github.com/matrix-org/matrix-js-sdk/pull/3479)). Contributed by @florianduros.
|
||||
* Close IDB database before deleting it to prevent spurious unexpected close errors ([\#3478](https://github.com/matrix-org/matrix-js-sdk/pull/3478)). Fixes vector-im/element-web#25597.
|
||||
|
||||
Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +23,8 @@ endpoints from before Matrix 1.1, for example.
|
||||
|
||||
## In a browser
|
||||
|
||||
### Note, the browserify build has been deprecated. Please use a bundler like webpack or vite instead.
|
||||
|
||||
Download the browser version from
|
||||
https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a
|
||||
`<script>` to your page. There will be a global variable `matrixcs`
|
||||
|
||||
+13
-12
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "26.1.0",
|
||||
"version": "28.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn build",
|
||||
@@ -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,13 +55,15 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.10",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0",
|
||||
"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.6.0",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
@@ -95,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.41.0",
|
||||
"eslint": "8.48.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",
|
||||
@@ -156,5 +156,6 @@
|
||||
"no-rust-crypto": {
|
||||
"src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
}
|
||||
|
||||
+1
-18
@@ -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
|
||||
*
|
||||
|
||||
@@ -18,8 +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, MatrixClient, IAuthDict, UIAuthCallback } from "../../../src";
|
||||
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
|
||||
@@ -38,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);
|
||||
@@ -51,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);
|
||||
});
|
||||
|
||||
@@ -62,45 +106,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return `{}` for `GET _matrix/client/r0/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)
|
||||
*/
|
||||
function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cross-signing keys, publish the keys
|
||||
* Mock and bootstrap all the required steps
|
||||
* Create cross-signing keys and publish the keys
|
||||
*
|
||||
* @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> {
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
async function bootstrapCrossSigning(authDict: AuthDict): Promise<void> {
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,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()", () => {
|
||||
@@ -187,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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+699
-140
File diff suppressed because it is too large
Load Diff
@@ -14,157 +14,786 @@ 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 { createClient, CryptoEvent, ICreateClientOpts, 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";
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
|
||||
/** 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 ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
type: "m.room.encrypted",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
sender_key: "SENDER_CURVE25519",
|
||||
session_id: SESSION_ID,
|
||||
ciphertext:
|
||||
"AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" +
|
||||
"CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" +
|
||||
"mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs",
|
||||
},
|
||||
room_id: "!ROOM:ID",
|
||||
event_id: "$event1",
|
||||
origin_server_ts: 1507753886000,
|
||||
};
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
|
||||
first_message_index: 0,
|
||||
forwarded_count: 0,
|
||||
is_verified: false,
|
||||
session_data: {
|
||||
ciphertext:
|
||||
"2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" +
|
||||
"6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" +
|
||||
"Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" +
|
||||
"SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" +
|
||||
"Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" +
|
||||
"ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" +
|
||||
"4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" +
|
||||
"C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" +
|
||||
"Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" +
|
||||
"QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" +
|
||||
"iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg",
|
||||
mac: "5lxYBHQU80M",
|
||||
ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14",
|
||||
},
|
||||
};
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
|
||||
const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// const newBackendOnly = backend === "libolm" ? 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 () {
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [ENCRYPTED_EVENT],
|
||||
events: [testData.ENCRYPTED_EVENT],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
testData.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.getCrypto()!.checkKeyBackupAndEnable();
|
||||
|
||||
// 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(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
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);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
let onKeyCached: () => void;
|
||||
const awaitKeyCached = new Promise<void>((resolve) => {
|
||||
onKeyCached = resolve;
|
||||
});
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
|
||||
await awaitKeyCached;
|
||||
});
|
||||
|
||||
it("recover specific session from backup", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
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(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Fails on bad recovery key", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
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);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
await expect(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
"EsTx A7Xn aNFF k3jH zpV3 MQoN LJEg mscC HecF 982L wC77 mYQD",
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,4 +87,19 @@ describe("MatrixClient.clearStores", () => {
|
||||
await matrixClient.clearStores();
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not fail in environments without indexedDB", async () => {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = undefined!;
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores();
|
||||
// No error thrown in clearStores
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,9 +92,7 @@ describe("MatrixClient events", function () {
|
||||
type: "m.room.create",
|
||||
room: "!erufh:bar",
|
||||
user: "@foo:bar",
|
||||
content: {
|
||||
creator: "@foo:bar",
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -107,9 +107,7 @@ const INITIAL_SYNC_DATA = {
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
user: userId,
|
||||
content: {
|
||||
creator: userId,
|
||||
},
|
||||
content: {},
|
||||
event: false,
|
||||
}),
|
||||
],
|
||||
@@ -207,7 +205,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 +246,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 +258,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 +280,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -598,12 +607,6 @@ describe("MatrixClient event timelines", function () {
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
@@ -634,12 +637,6 @@ describe("MatrixClient event timelines", function () {
|
||||
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
|
||||
await httpBackend.flushAllExpected();
|
||||
const timelineSet = thread.timelineSet;
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
await flushHttp(emitPromise(thread, ThreadEvent.Update));
|
||||
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
|
||||
|
||||
@@ -790,7 +787,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(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1274,7 +1282,6 @@ describe("MatrixClient event timelines", function () {
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_ROOT_REACTION.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
@@ -1333,7 +1340,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!,
|
||||
}),
|
||||
@@ -1351,7 +1358,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!,
|
||||
}),
|
||||
@@ -1362,7 +1369,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,
|
||||
}),
|
||||
);
|
||||
@@ -1510,7 +1517,8 @@ describe("MatrixClient event timelines", function () {
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
// this has to come after THREAD_REPLY which hasn't been instantiated by us
|
||||
THREAD_REPLY2.localTimestamp += 10000000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
@@ -1570,9 +1578,6 @@ describe("MatrixClient event timelines", function () {
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.NewReply);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -1699,13 +1704,11 @@ describe("MatrixClient event timelines", function () {
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY_REACTION]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
expect(thread.length).toBe(1); // reactions don't count towards the length of a thread
|
||||
// Test thread order is unchanged
|
||||
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
@@ -2047,71 +2050,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(thread.initialEventsFetched).toBeTruthy();
|
||||
const timelineSet = thread.timelineSet;
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.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", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.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", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return {
|
||||
start: "start_token",
|
||||
events_before: [],
|
||||
event: THREAD_ROOT,
|
||||
events_after: [],
|
||||
end: "end_token",
|
||||
state: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
from: "start_token",
|
||||
}),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Forward, from: "end_token" }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [THREAD_REPLY],
|
||||
};
|
||||
});
|
||||
|
||||
const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!);
|
||||
|
||||
httpBackend.when("GET", "/sync").respond(200, {
|
||||
next_batch: "s_5_5",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,9 +57,7 @@ describe("MatrixClient opts", function () {
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: userId,
|
||||
content: {
|
||||
creator: userId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -85,9 +85,7 @@ describe("MatrixClient room timelines", function () {
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: userId,
|
||||
content: {
|
||||
creator: userId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -222,9 +223,122 @@ describe("MatrixClient syncing", () => {
|
||||
expect(fires).toBe(3);
|
||||
});
|
||||
|
||||
it("should honour lazyLoadMembers if user is not a guest", () => {
|
||||
client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true);
|
||||
it("should emit RoomEvent.MyMembership for knock->leave->knock cycles", async () => {
|
||||
await client!.initCrypto();
|
||||
|
||||
const roomId = "!cycles:example.org";
|
||||
|
||||
// First sync: an knock
|
||||
const knockSyncRoomSection = {
|
||||
knock: {
|
||||
[roomId]: {
|
||||
knock_state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// Second sync: a leave (reject of some kind)
|
||||
httpBackend!.when("POST", "/leave").respond(200, {});
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: {
|
||||
leave: {
|
||||
[roomId]: {
|
||||
account_data: { events: [] },
|
||||
ephemeral: { events: [] },
|
||||
state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
// XXX: And other fields required on an event
|
||||
},
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
limited: false,
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
// XXX: And other fields required on an event
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Third sync: another knock
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// First fire: an initial knock
|
||||
let fires = 0;
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
// Room, string, string
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("knock");
|
||||
expect(oldMembership).toBeFalsy();
|
||||
|
||||
// Second fire: a leave
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("leave");
|
||||
expect(oldMembership).toBe("knock");
|
||||
|
||||
// Third/final fire: a second knock
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("knock");
|
||||
expect(oldMembership).toBe("leave");
|
||||
});
|
||||
});
|
||||
|
||||
// For maximum safety, "leave" the room after we register the handler
|
||||
client!.leave(roomId);
|
||||
});
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(fires).toBe(3);
|
||||
});
|
||||
|
||||
it("should honour lazyLoadMembers if user is not a guest", () => {
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
@@ -241,8 +355,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) => {
|
||||
@@ -296,6 +408,46 @@ describe("MatrixClient syncing", () => {
|
||||
expect(fires).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit ClientEvent.Room when knocked while crypto is disabled", async () => {
|
||||
const roomId = "!knock:example.org";
|
||||
|
||||
// First sync: a knock
|
||||
const knockSyncRoomSection = {
|
||||
knock: {
|
||||
[roomId]: {
|
||||
knock_state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// First fire: an initial knock
|
||||
let fires = 0;
|
||||
client!.once(ClientEvent.Room, (room) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
});
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(fires).toBe(1);
|
||||
});
|
||||
|
||||
it("should work when all network calls fail", async () => {
|
||||
httpBackend!.expectedRequests = [];
|
||||
httpBackend!.when("GET", "").fail(0, new Error("CORS or something"));
|
||||
@@ -361,6 +513,7 @@ describe("MatrixClient syncing", () => {
|
||||
join: {},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -392,9 +545,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -580,9 +731,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -614,9 +763,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomTwo,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -761,7 +908,6 @@ describe("MatrixClient syncing", () => {
|
||||
room: roomOne,
|
||||
user: otherUserId,
|
||||
content: {
|
||||
creator: otherUserId,
|
||||
room_version: "9",
|
||||
},
|
||||
});
|
||||
@@ -847,7 +993,6 @@ describe("MatrixClient syncing", () => {
|
||||
room: roomOne,
|
||||
user: otherUserId,
|
||||
content: {
|
||||
creator: otherUserId,
|
||||
room_version: testMeta.roomVersion,
|
||||
},
|
||||
});
|
||||
@@ -1375,9 +1520,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
} as Partial<IJoinedRoom>,
|
||||
@@ -1474,9 +1617,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -1590,6 +1731,66 @@ 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: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} 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", () => {
|
||||
|
||||
@@ -179,7 +179,6 @@ describe("MatrixClient syncing", () => {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
creator: userB,
|
||||
room_version: "9",
|
||||
},
|
||||
origin_server_ts: 1,
|
||||
@@ -377,6 +376,7 @@ describe("MatrixClient syncing", () => {
|
||||
},
|
||||
[Category.Leave]: {},
|
||||
[Category.Invite]: {},
|
||||
[Category.Knock]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -188,7 +188,7 @@ describe("SlidingSyncSdk", () => {
|
||||
[roomA]: {
|
||||
name: "A",
|
||||
required_state: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnStateEvent(EventType.RoomName, { name: "A" }, ""),
|
||||
@@ -203,7 +203,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "B",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello B" }),
|
||||
@@ -215,7 +215,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "C",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello C" }),
|
||||
@@ -228,7 +228,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "D",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello D" }),
|
||||
@@ -264,7 +264,7 @@ describe("SlidingSyncSdk", () => {
|
||||
[roomF]: {
|
||||
name: "#foo:localhost",
|
||||
required_state: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCanonicalAlias, { alias: "#foo:localhost" }, ""),
|
||||
@@ -280,7 +280,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "G",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
],
|
||||
@@ -292,7 +292,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "H",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "live event" }),
|
||||
@@ -602,7 +602,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "Room with Invite",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "invite" }, invitee),
|
||||
@@ -718,7 +718,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "Room with account data",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello" }),
|
||||
@@ -922,7 +922,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "Room with typing",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello" }),
|
||||
@@ -963,7 +963,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "Room with typing",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
mkOwnEvent(EventType.RoomMessage, { body: "hello" }),
|
||||
@@ -1049,7 +1049,7 @@ describe("SlidingSyncSdk", () => {
|
||||
name: "Room with receipts",
|
||||
required_state: [],
|
||||
timeline: [
|
||||
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
|
||||
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
||||
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
|
||||
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -145,6 +143,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
return this.deviceKeys.keys[keyIds[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the device keys have already been uploaded, return them. Else return null.
|
||||
*/
|
||||
public getUploadedDeviceKeys(): IDeviceKeys | null {
|
||||
return this.deviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If one-time keys have already been uploaded, return them. Otherwise,
|
||||
* set up an expectation that the keys will be uploaded, and wait for
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { MapWithDefault } from "../../src/utils";
|
||||
import { IDownloadKeyResult } from "../../src";
|
||||
import { IDeviceKeys } from "../../src/@types/crypto";
|
||||
import { E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
|
||||
/**
|
||||
* An object which intercepts `/keys/query` fetches via fetch-mock.
|
||||
*/
|
||||
export class E2EKeyResponder {
|
||||
private deviceKeysByUserByDevice = new MapWithDefault<string, Map<string, any>>(() => new Map());
|
||||
private e2eKeyReceiversByUser = new Map<string, E2EKeyReceiver>();
|
||||
private masterKeysByUser: Record<string, any> = {};
|
||||
private selfSigningKeysByUser: Record<string, any> = {};
|
||||
private userSigningKeysByUser: Record<string, any> = {};
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl.
|
||||
* Only /query requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
// set up a listener for /keys/query.
|
||||
const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener);
|
||||
}
|
||||
|
||||
private onKeyQueryRequest(options: RequestInit) {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const usersToReturn = Object.keys(content["device_keys"]);
|
||||
const response = {
|
||||
device_keys: {} as { [userId: string]: any },
|
||||
master_keys: {} as { [userId: string]: any },
|
||||
self_signing_keys: {} as { [userId: string]: any },
|
||||
user_signing_keys: {} as { [userId: string]: any },
|
||||
failures: {} as { [serverName: string]: any },
|
||||
};
|
||||
for (const user of usersToReturn) {
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
||||
}
|
||||
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||
if (e2eKeyReceiver !== undefined) {
|
||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||
if (deviceKeys !== null) {
|
||||
response.device_keys[user] ??= {};
|
||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||
response.master_keys[user] = this.masterKeysByUser[user];
|
||||
}
|
||||
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
|
||||
}
|
||||
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
|
||||
*
|
||||
* @param keys - device keys for this device.
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @param data cross-signing data
|
||||
*/
|
||||
public addCrossSigningData(
|
||||
data: Pick<IDownloadKeyResult, "master_keys" | "self_signing_keys" | "user_signing_keys">,
|
||||
) {
|
||||
Object.assign(this.masterKeysByUser, data.master_keys);
|
||||
Object.assign(this.selfSigningKeysByUser, data.self_signing_keys);
|
||||
Object.assign(this.userSigningKeysByUser, data.user_signing_keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an E2EKeyReceiver to poll for uploaded keys
|
||||
*
|
||||
* Any keys which have been uploaded to the given `E2EKeyReceiver` at the time of the `/keys/query` request will
|
||||
* be added to the response.
|
||||
*
|
||||
* @param e2eKeyReceiver
|
||||
*/
|
||||
public addKeyReceiver(userId: string, e2eKeyReceiver: E2EKeyReceiver) {
|
||||
this.e2eKeyReceiversByUser.set(userId, e2eKeyReceiver);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -16,15 +16,77 @@ limitations under the License.
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { KeyBackupInfo } from "../../src/crypto-api";
|
||||
|
||||
/**
|
||||
* Mock out the endpoints that the js-sdk calls when we call `MatrixClient.start()`.
|
||||
*
|
||||
* @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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* 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/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" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock out requests to `/room_keys/version`.
|
||||
*
|
||||
* Returns `404 M_NOT_FOUND` for GET requests until `POST room_keys/version` is called.
|
||||
* Once the POST is done, `GET /room_keys/version` will return the posted backup
|
||||
* instead of 404.
|
||||
*
|
||||
* @param backupVersion - The backup version that will be returned by `POST room_keys/version`.
|
||||
*/
|
||||
export function mockSetupMegolmBackupRequests(backupVersion: string): void {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No current backup version",
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/room_keys/version", (url, request) => {
|
||||
const backupData: KeyBackupInfo = JSON.parse(request.body?.toString() ?? "{}");
|
||||
backupData.version = backupVersion;
|
||||
backupData.count = 0;
|
||||
backupData.etag = "zer";
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupData, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
return {
|
||||
version: backupVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -26,52 +26,56 @@ python -m venv env
|
||||
|
||||
import base64
|
||||
import json
|
||||
import base58
|
||||
|
||||
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 cryptography.hazmat.primitives import hashes, padding, hmac
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
# 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"
|
||||
from random import randbytes, seed
|
||||
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
|
||||
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",
|
||||
# any 32-byte string can be an curve25519 private key.
|
||||
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"deadmuledeadmuledeadmuledeadmule",
|
||||
|
||||
"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",
|
||||
# any 32-byte string can be an curve25519 private key.
|
||||
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"Deadmuledeadmuledeadmuledeadmule",
|
||||
|
||||
"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 +83,213 @@ def main() -> None:
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
|
||||
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* 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"]
|
||||
)
|
||||
|
||||
device_curve_key = x25519.X25519PrivateKey.from_private_bytes(
|
||||
user_data["TEST_DEVICE_CURVE_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(device_curve_key)[0], build_exported_megolm_key(device_curve_key)[0]]
|
||||
|
||||
additional_exported_room_key, additional_exported_ed_key = build_exported_megolm_key(device_curve_key)
|
||||
ratcheted_exported_room_key = symetric_ratchet_step_of_megolm_key(additional_exported_room_key, additional_exported_ed_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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
backed_up_room_key = encrypt_megolm_key_for_backup(additional_exported_room_key, backup_decryption_key.public_key())
|
||||
|
||||
clear_event, encrypted_event = generate_encrypted_event_content(additional_exported_room_key, additional_exported_ed_key, device_curve_key)
|
||||
|
||||
backup_recovery_key = export_recovery_key(user_data["B64_BACKUP_DECRYPTION_KEY"])
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_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)
|
||||
};
|
||||
|
||||
/** A ratcheted version of {prefix}MEGOLM_SESSION_DATA */
|
||||
export const {prefix}RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
json.dumps(ratcheted_exported_room_key, indent=4)
|
||||
};
|
||||
|
||||
/** The key from {prefix}MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const {prefix}CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {json.dumps(backed_up_room_key, indent=4)};
|
||||
|
||||
/** A test clear event */
|
||||
export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, indent=4)};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, 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 +297,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 +307,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 +373,282 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
|
||||
return signature_base64
|
||||
|
||||
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
|
||||
"""
|
||||
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.
|
||||
Returns the exported key, the matching privat edKey (needed to encrypt)
|
||||
"""
|
||||
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": "!room:id",
|
||||
"sender_key": encode_base64(
|
||||
device_curve_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
),
|
||||
"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, private_key
|
||||
|
||||
def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed25519.Ed25519PrivateKey) -> dict:
|
||||
|
||||
"""
|
||||
Very simple ratchet step from 0 to 1
|
||||
Used to generate a ratcheted key to test unknown message index.
|
||||
"""
|
||||
session_key: str = previous["session_key"]
|
||||
|
||||
# Get the megolm R0 from the export format
|
||||
decoded = base64.b64decode(session_key.encode("ascii"))
|
||||
ri = decoded[5:133]
|
||||
|
||||
ri0 = ri[0:32]
|
||||
ri1 = ri[32:64]
|
||||
ri2 = ri[64:96]
|
||||
ri3 = ri[96:128]
|
||||
|
||||
h = hmac.HMAC(ri3, hashes.SHA256())
|
||||
h.update(b'x\03')
|
||||
ri1_3 = h.finalize()
|
||||
|
||||
index = 1
|
||||
private_key = megolm_private_key
|
||||
|
||||
# exported key, start with version byte
|
||||
exported_key = bytearray(b'\x01')
|
||||
exported_key += index.to_bytes(4, 'big')
|
||||
exported_key += ri0
|
||||
exported_key += ri1
|
||||
exported_key += ri2
|
||||
exported_key += ri1_3
|
||||
# KPub
|
||||
exported_key += private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
|
||||
|
||||
megolm_export = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": previous["sender_key"],
|
||||
"session_id": previous["session_id"],
|
||||
"session_key": encode_base64(exported_key),
|
||||
"sender_claimed_keys": previous["sender_claimed_keys"],
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
}
|
||||
|
||||
return megolm_export
|
||||
|
||||
def encrypt_megolm_key_for_backup(session_data: dict, backup_public_key: x25519.X25519PublicKey) -> dict:
|
||||
|
||||
"""
|
||||
Encrypts an exported megolm key for key backup, using the m.megolm_backup.v1.curve25519-aes-sha2 algorithm.
|
||||
"""
|
||||
data = encode_canonical_json(session_data)
|
||||
|
||||
# Generate an ephemeral curve25519 key, and perform an ECDH with the ephemeral key
|
||||
# and the backup’s public key to generate a shared secret.
|
||||
# The public half of the ephemeral key, encoded using unpadded base64,
|
||||
# becomes the ephemeral property of the session_data.
|
||||
ephemeral_keypair = x25519.X25519PrivateKey.from_private_bytes(randbytes(32))
|
||||
shared_secret = ephemeral_keypair.exchange(backup_public_key)
|
||||
ephemeral = encode_base64(ephemeral_keypair.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))
|
||||
|
||||
# Using the shared secret, generate 80 bytes by performing an HKDF using SHA-256 as the hash,
|
||||
# with a salt of 32 bytes of 0, and with the empty string as the info.
|
||||
# The first 32 bytes are used as the AES key, the next 32 bytes are used as the MAC key,
|
||||
# and the last 16 bytes are used as the AES initialization vector.
|
||||
salt = bytes(32)
|
||||
info = b""
|
||||
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=80,
|
||||
salt=salt,
|
||||
info=info,
|
||||
)
|
||||
|
||||
raw_key = hkdf.derive(shared_secret)
|
||||
aes_key = raw_key[:32]
|
||||
mac = raw_key[32:64]
|
||||
iv = raw_key[64:80]
|
||||
|
||||
# Stringify the JSON object, and encrypt it using AES-CBC-256 with PKCS#7 padding.
|
||||
# This encrypted data, encoded using unpadded base64, becomes the ciphertext property of the session_data.
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
||||
encryptor = cipher.encryptor()
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded_data = padder.update(data) + padder.finalize()
|
||||
ct = encryptor.update(padded_data) + encryptor.finalize()
|
||||
cipher_text = encode_base64(ct)
|
||||
|
||||
# Pass the raw encrypted data (prior to base64 encoding) through HMAC-SHA-256 using the MAC key generated above.
|
||||
# The first 8 bytes of the resulting MAC are base64-encoded, and become the mac property of the session_data.
|
||||
h = hmac.HMAC(mac, hashes.SHA256())
|
||||
# h.update(ct)
|
||||
signature = h.finalize()
|
||||
mac = encode_base64(signature[:8])
|
||||
|
||||
encrypted_key = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": False,
|
||||
"session_data": {
|
||||
"ciphertext": cipher_text,
|
||||
"ephemeral": ephemeral,
|
||||
"mac": mac
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return encrypted_key
|
||||
|
||||
def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519PrivateKey, curve_key: x25519.X25519PrivateKey) -> tuple[dict, dict]:
|
||||
"""
|
||||
Encrypts an event using the given key in session export format.
|
||||
Will not do any ratcheting, just encrypt at index 0.
|
||||
"""
|
||||
|
||||
clear_event = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
}
|
||||
|
||||
session_key: str = exported_key["session_key"]
|
||||
|
||||
# Get the megolm R0 from the export format
|
||||
decoded = base64.b64decode(session_key.encode("ascii"))
|
||||
r0 = decoded[5:133]
|
||||
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=80,
|
||||
salt=bytes(32),
|
||||
info=b"MEGOLM_KEYS",
|
||||
)
|
||||
|
||||
raw_key = hkdf.derive(r0)
|
||||
aes_key = raw_key[:32]
|
||||
mac = raw_key[32:64]
|
||||
aes_iv = raw_key[64:80]
|
||||
|
||||
payload_json = {
|
||||
"room_id": clear_event["room_id"],
|
||||
"type": clear_event["type"],
|
||||
"content": clear_event["content"]
|
||||
}
|
||||
|
||||
payload_string = encode_canonical_json(payload_json)
|
||||
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(aes_iv))
|
||||
encryptor = cipher.encryptor()
|
||||
padder = padding.PKCS7(128).padder()
|
||||
|
||||
padded_data = padder.update(payload_string)
|
||||
padded_data += padder.finalize()
|
||||
|
||||
ct = encryptor.update(padded_data) + encryptor.finalize()
|
||||
|
||||
# The ratchet index i, and the cipher-text, are then packed
|
||||
# into a message as described in Message format. Then the entire message
|
||||
# (including the version bytes and all payload bytes) are passed through
|
||||
# HMAC-SHA-256. The first 8 bytes of the MAC are appended to the message.
|
||||
message = bytearray()
|
||||
message += b'\x03'
|
||||
# int tag for index
|
||||
message += b'\x08'
|
||||
# index is 0
|
||||
message += b'\x00'
|
||||
message += b'\x12'
|
||||
# probably works only for short messages
|
||||
message += len(ct).to_bytes(1, 'big')
|
||||
# encrypted data
|
||||
message += ct
|
||||
|
||||
h = hmac.HMAC(mac, hashes.SHA256())
|
||||
h.update(message)
|
||||
signature = h.finalize()
|
||||
mac = signature[:8]
|
||||
|
||||
message += mac
|
||||
|
||||
# Finally, the authenticated message is signed using the Ed25519 keypair;
|
||||
# the 64 byte signature is appended to the message
|
||||
signature = ed_key.sign(bytes(message))
|
||||
|
||||
message += signature
|
||||
|
||||
cipher_text = encode_base64(message)
|
||||
|
||||
encrypted_payload = {
|
||||
"algorithm" : "m.megolm.v1.aes-sha2",
|
||||
"sender_key" : encode_base64(curve_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
|
||||
"ciphertext" : cipher_text,
|
||||
"session_id" : exported_key["session_id"],
|
||||
"device_id" : "TEST_DEVICE"
|
||||
}
|
||||
|
||||
encrypted_event = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": encrypted_payload,
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000,
|
||||
}
|
||||
|
||||
return clear_event, encrypted_event
|
||||
|
||||
|
||||
def export_recovery_key(key_b64: str) -> str:
|
||||
"""
|
||||
Export a private recovery key as a recovery key that can be presented to users.
|
||||
As per spec https://spec.matrix.org/v1.8/client-server-api/#recovery-key
|
||||
"""
|
||||
private_key_bytes = base64.b64decode(key_b64)
|
||||
|
||||
# The 256-bit curve25519 private key is prepended by the bytes 0x8B and 0x01
|
||||
export_bytes = bytearray()
|
||||
export_bytes += b'\x8b'
|
||||
export_bytes += b'\x01'
|
||||
|
||||
export_bytes += private_key_bytes
|
||||
|
||||
# All the bytes in the string above, including the two header bytes,
|
||||
# are XORed together to form a parity byte. This parity byte is appended to the byte string.
|
||||
parity_byte = 0 #b'\x8b' ^ b'\x01'
|
||||
[parity_byte := parity_byte ^ x for x in export_bytes]
|
||||
|
||||
export_bytes += parity_byte.to_bytes(1, 'big')
|
||||
|
||||
# The byte string is encoded using base58
|
||||
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
|
||||
|
||||
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
|
||||
return ' '.join(split)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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 { IDownloadKeyResult } from "../../../src";
|
||||
import { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult, IEvent } from "../../../src";
|
||||
import { KeyBackupSession, KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* 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,351 @@ export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
|
||||
/** 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": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"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": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"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": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"session_key": "AQAAAABXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIXBlK8tAfJo3cJnlh7F4ltEOAqrdME6dU0zXTkqXmURqYqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** A ratcheted version of MEGOLM_SESSION_DATA */
|
||||
export const RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"session_key": "AQAAAAFXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIUWvpwC7by/yg231+gyzu9lDHAU4ivCj48pt7WGiORWmIqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** The key from MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
|
||||
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
|
||||
"mac": "OibmACbORhI"
|
||||
}
|
||||
};
|
||||
|
||||
/** A test clear event */
|
||||
export const CLEAR_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"ciphertext": "AwgAEnAkBmciEAyhh1j6DCk29UXJ7kv/kvayUNfuNT0iAioLxcXjFXOZ5ho3jF1/wrytlt0Lb298uMM67OxdVMi+/mMfYpwlvi07P9cIH6CMSj8tyhYoWl0SrKY6tkPf5GWOlRSRRKbziXa96FHXvnA3V2FCAIGtAe3G4ei5RPbhkmKAFBLAen33/D6MjJVqU8Ojr5vTkgls5eyirarlVpsmnH06alDaxO8avrU0NL+Vsw26xvlUQgEMOnUJ",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"device_id": "TEST_DEVICE"
|
||||
},
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
|
||||
|
||||
/** 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": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "/2K+V777vipCxPZ0gpY9qcpz1DYaXwuMRIu0UEP0Wa0",
|
||||
"session_key": "AQAAAAAclzWVMeWBKH+B/WMowa3rb4ma3jEl6n5W4GCs9ue65CruzD3ihX+85pZ9hsV9Bf6fvhjp76WNRajoJYX0UIt7aosjmu0i+H+07hEQ0zqTKpVoSH0ykJ6stAMhdr6Q4uW5crBmdTTBIsqmoWsNJZKKoE2+ldYrZ1lrFeaJbjBIY/9ivle++74qQsT2dIKWPanKc9Q2Gl8LjESLtFBD9Fmt",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "+07YOpSgdZ1X9le3n3NMByw0V1B0H0Djnbm76jgmWoo",
|
||||
"session_key": "AQAAAAAjWfIMo9+BWS8IvhfsQuomxXXXGy11tJs0ej505xxd1RzOIP4ftq3MbZYsfH8kqSMBc2l1Ym2u3Dksv2/nR0zGQeNIgOxeMuwHU3Ry7+DdV1I96blPylVCCn/f5RAy6smKoaeylptPdXgVXmw3YBBUVYpHpm+xCIUUp9foAdb8hftO2DqUoHWdV/ZXt59zTAcsNFdQdB9A4525u+o4JlqK",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
}
|
||||
];
|
||||
|
||||
/** An exported megolm session */
|
||||
export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"session_key": "AQAAAADZJL7OdM/KHfPzXPZ3CtlLBIlzbwk06dnZTd3bvkcdP5u73rdmThBKdqGA4xzCyxZsHdYLZRrlmD3VwOmNfvWMqYdPxA1X0vs3d172y9EIG8i+N/skJxTRypcVSV9XoinBNIWr/gkyepuAKiQqemlc8J5amD9OkmbVkmnrxP1uyYMsMnQayCXCVpLQv4nN7bpymTNV2ZtTKWImLXW4/EaX",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
|
||||
export const BOB_RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"session_key": "AQAAAAHZJL7OdM/KHfPzXPZ3CtlLBIlzbwk06dnZTd3bvkcdP5u73rdmThBKdqGA4xzCyxZsHdYLZRrlmD3VwOmNfvWMqYdPxA1X0vs3d172y9EIG8i+N/skJxTRypcVSV9Xoil2JdGx9oPqR0dFVh661Aqs86rJRbQ4IeRiuEm35VMxboMsMnQayCXCVpLQv4nN7bpymTNV2ZtTKWImLXW4/EaX",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** The key from BOB_MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
|
||||
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
|
||||
"mac": "lEfHlqfJQwU"
|
||||
}
|
||||
};
|
||||
|
||||
/** A test clear event */
|
||||
export const BOB_CLEAR_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"ciphertext": "AwgAEnA/mEqZm2lSrhoG11OpDqsohGSBJWsudbuoItLlivmpFZQHrKMbE6z/dhCTwUi76vwfRCtf4tyPMD845cqZH1nL0bowq3/awyzZ8Q263Y3WrLfkUTFBU6oPF/IULUFZZuw6kLdfd5g5+uigvqUhFFpICoj7KNHznv4sFNssd00/WgJquZ6PRt6e1v6ANFNiZPAwghIL+kBc6pb8i6MUWt9JnXilJhTqFDHdXiY4qkaKBWbwebC26PYM",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"device_id": "TEST_DEVICE"
|
||||
},
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
|
||||
@@ -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,62 @@ 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: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spy for an object and automatically spy its methods.
|
||||
* @param constr - The class constructor (used with 'new')
|
||||
@@ -455,10 +521,19 @@ export async function awaitDecryption(
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
if (waitOnDecryptionFailure) {
|
||||
event.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
if (!err) {
|
||||
resolve(ev);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const mkThread = ({
|
||||
room?.reEmitter.reEmit(evt, [MatrixEventEvent.BeforeRedaction]);
|
||||
}
|
||||
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true);
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, [rootEvent, ...events], true);
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
|
||||
@@ -485,6 +485,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
|
||||
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
|
||||
public getRoom = jest.fn();
|
||||
public getFoci = jest.fn();
|
||||
|
||||
public supportsThreads(): boolean {
|
||||
return true;
|
||||
|
||||
+129
-16
@@ -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 { OidcDiscoveryError } from "../../src/oidc/validate";
|
||||
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": {
|
||||
@@ -400,7 +411,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
@@ -441,7 +452,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
@@ -485,7 +496,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "FAIL_ERROR",
|
||||
error: OidcDiscoveryError.Misconfigured,
|
||||
error: OidcError.Misconfigured,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
@@ -719,7 +730,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
@@ -775,7 +786,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+126
-2
@@ -15,11 +15,16 @@ import { sleep } from "../../src/utils";
|
||||
import { CRYPTO_ENABLED } from "../../src/client";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { logger } from "../../src/logger";
|
||||
import { MemoryStore } from "../../src";
|
||||
import { DeviceVerification, MemoryStore } from "../../src";
|
||||
import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager";
|
||||
import { RoomMember } from "../../src/models/room-member";
|
||||
import { IStore } from "../../src/store";
|
||||
import { IRoomEncryption, RoomList } from "../../src/crypto/RoomList";
|
||||
import { EventShieldColour, EventShieldReason } from "../../src/crypto-api";
|
||||
import { UserTrustLevel } from "../../src/crypto/CrossSigning";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
import { EventDecryptionResult } from "../../src/common-crypto/CryptoBackend";
|
||||
import * as testData from "../test-utils/test-data";
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
@@ -111,13 +116,14 @@ describe("Crypto", function () {
|
||||
});
|
||||
|
||||
describe("encrypted events", function () {
|
||||
it("provides encryption information", async function () {
|
||||
it("provides encryption information for events from unverified senders", async function () {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
// unencrypted event
|
||||
const event = {
|
||||
getId: () => "$event_id",
|
||||
getSender: () => "@bob:example.com",
|
||||
getSenderKey: () => null,
|
||||
getWireContent: () => {
|
||||
return {};
|
||||
@@ -127,6 +133,8 @@ describe("Crypto", function () {
|
||||
let encryptionInfo = client.getEventEncryptionInfo(event);
|
||||
expect(encryptionInfo.encrypted).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null);
|
||||
|
||||
// unknown sender (e.g. deleted device), forwarded megolm key (untrusted)
|
||||
event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI";
|
||||
event.getWireContent = () => {
|
||||
@@ -141,6 +149,11 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.authenticated).toBeFalsy();
|
||||
expect(encryptionInfo.sender).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
|
||||
// known sender, megolm key from backup
|
||||
event.getForwardingCurve25519KeyChain = () => [];
|
||||
event.isKeySourceUntrusted = () => true;
|
||||
@@ -155,6 +168,11 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.sender).toBeTruthy();
|
||||
expect(encryptionInfo.mismatchedSender).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
|
||||
// known sender, trusted megolm key, but bad ed25519key
|
||||
event.isKeySourceUntrusted = () => false;
|
||||
device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
@@ -165,9 +183,115 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.sender).toBeTruthy();
|
||||
expect(encryptionInfo.mismatchedSender).toBeTruthy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY,
|
||||
});
|
||||
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
describe("provides encryption information for events from verified senders", function () {
|
||||
const testDeviceId = testData.BOB_TEST_DEVICE_ID;
|
||||
const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA;
|
||||
|
||||
let client: MatrixClient;
|
||||
beforeEach(async () => {
|
||||
client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
// mock out the verification check
|
||||
client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
async function buildEncryptedEvent(
|
||||
decryptionResult: Partial<EventDecryptionResult> = {},
|
||||
): Promise<MatrixEvent> {
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: async (event: MatrixEvent): Promise<EventDecryptionResult> => {
|
||||
return {
|
||||
claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId],
|
||||
clearEvent: {
|
||||
room_id: "!room_id",
|
||||
type: "m.room.message",
|
||||
content: { body: "test" },
|
||||
},
|
||||
forwardingCurve25519KeyChain: [],
|
||||
senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId],
|
||||
...decryptionResult,
|
||||
};
|
||||
},
|
||||
} as unknown as CryptoBackend;
|
||||
|
||||
const event = new MatrixEvent({
|
||||
event_id: "$event_id",
|
||||
sender: testData.BOB_TEST_USER_ID,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
});
|
||||
await event.attemptDecryption(mockCryptoBackend);
|
||||
return event;
|
||||
}
|
||||
|
||||
it("unknown device", async () => {
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
});
|
||||
});
|
||||
|
||||
it("known but unsigned device", async () => {
|
||||
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
|
||||
[testDeviceId]: {
|
||||
keys: testDevice.keys,
|
||||
algorithms: testDevice.algorithms,
|
||||
verified: DeviceVerification.Unverified,
|
||||
known: true,
|
||||
},
|
||||
});
|
||||
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNVERIFIED_IDENTITY,
|
||||
});
|
||||
});
|
||||
|
||||
describe("known and verified device", () => {
|
||||
beforeEach(() => {
|
||||
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
|
||||
[testDeviceId]: {
|
||||
keys: testDevice.keys,
|
||||
algorithms: testDevice.algorithms,
|
||||
verified: DeviceVerification.Verified,
|
||||
known: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("regular key", async () => {
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("unauthenticated key", async () => {
|
||||
const event = await buildEncryptedEvent({ untrusted: true });
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../../src/common-crypto/base64";
|
||||
|
||||
describe("Crypto Base64 encoding", () => {
|
||||
it("Should decode properly encoded data", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const encoded = encodeBase64(new TextEncoder().encode(toEncode));
|
||||
const decoded = new TextDecoder().decode(decodeBase64(encoded));
|
||||
|
||||
expect(decoded).toStrictEqual(toEncode);
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const paddedEncoded = encodeBase64(data);
|
||||
const unpaddedEncoded = encodeUnpaddedBase64(data);
|
||||
|
||||
expect(paddedEncoded).not.toEqual(unpaddedEncoded);
|
||||
|
||||
const padding = paddedEncoded.charAt(paddedEncoded.length - 1);
|
||||
expect(padding).toStrictEqual("=");
|
||||
});
|
||||
|
||||
it("Decode should be indifferent to padding", async () => {
|
||||
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
|
||||
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";
|
||||
|
||||
const decodedPad = decodeBase64(withPadding);
|
||||
const decodedNoPad = decodeBase64(withoutPadding);
|
||||
|
||||
expect(decodedPad).toStrictEqual(decodedNoPad);
|
||||
});
|
||||
});
|
||||
@@ -312,6 +312,7 @@ describe("Secrets", function () {
|
||||
this.emit(ClientEvent.AccountData, event);
|
||||
return {};
|
||||
};
|
||||
bob.getKeyBackupVersion = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await bob.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (func) => {
|
||||
|
||||
@@ -23,7 +23,14 @@ limitations under the License.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
import { MockedObject } from "jest-mock";
|
||||
import { WidgetApi, WidgetApiToWidgetAction, MatrixCapabilities, ITurnServer, IRoomEvent } from "matrix-widget-api";
|
||||
import {
|
||||
WidgetApi,
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
ITurnServer,
|
||||
IRoomEvent,
|
||||
IOpenIDCredentials,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { createRoomWidgetClient, MsgType } from "../../src/matrix";
|
||||
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client";
|
||||
@@ -33,6 +40,12 @@ import { MatrixEvent } from "../../src/models/event";
|
||||
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
|
||||
const testOIDCToken = {
|
||||
access_token: "12345678",
|
||||
expires_in: "10",
|
||||
matrix_server_name: "homeserver.oabc",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn();
|
||||
public requestCapability = jest.fn();
|
||||
@@ -49,8 +62,15 @@ class MockWidgetApi extends EventEmitter {
|
||||
public sendRoomEvent = jest.fn(() => ({ event_id: `$${Math.random()}` }));
|
||||
public sendStateEvent = jest.fn();
|
||||
public sendToDevice = jest.fn();
|
||||
public requestOpenIDConnectToken = jest.fn(() => {
|
||||
return testOIDCToken;
|
||||
return new Promise<IOpenIDCredentials>(() => {
|
||||
return testOIDCToken;
|
||||
});
|
||||
});
|
||||
public readStateEvents = jest.fn(() => []);
|
||||
public getTurnServers = jest.fn(() => []);
|
||||
public sendContentLoaded = jest.fn();
|
||||
|
||||
public transport = { reply: jest.fn() };
|
||||
}
|
||||
@@ -285,7 +305,12 @@ describe("RoomWidgetClient", () => {
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oidc token", () => {
|
||||
it("requests an oidc token", async () => {
|
||||
await makeClient({});
|
||||
expect(await client.getOpenIdToken()).toStrictEqual(testOIDCToken);
|
||||
});
|
||||
});
|
||||
it("gets TURN servers", async () => {
|
||||
const server1: ITurnServer = {
|
||||
uris: [
|
||||
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import {
|
||||
DuplicateStrategy,
|
||||
@@ -160,6 +162,33 @@ describe("EventTimelineSet", () => {
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should aggregate relations which belong to unknown timeline without adding them to any timeline", () => {
|
||||
// If threads are disabled all events go into the main timeline
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
const reactionEvent = utils.mkReaction(messageEvent, client, client.getSafeUserId(), roomId);
|
||||
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents()).toHaveLength(1);
|
||||
const [event] = liveTimeline.getEvents();
|
||||
const reactions = eventTimelineSet.relations!.getChildEventsForEvent(
|
||||
event.getId()!,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)!;
|
||||
const relations = reactions.getRelations();
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0].getId()).toBe(reactionEvent.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventToTimeline (thread timeline)", () => {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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]",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -94,7 +94,6 @@ describe("InteractiveAuth", () => {
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Password] }],
|
||||
errcode: "MockError0",
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
@@ -376,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();
|
||||
@@ -560,4 +559,45 @@ describe("InteractiveAuth", () => {
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should fire stateUpdated callback with error when a request fails", 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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// StateUpdated should be called. We call submitAuthDict() to trigger a request ...
|
||||
let firstTime = true;
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
// Only trigger the request the first time, to avoid an infinite loop
|
||||
if (firstTime) {
|
||||
firstTime = false;
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// .. which which we then reject, so we can test the behaviour in that case.
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -2266,7 +2266,6 @@ describe("MatrixClient", function () {
|
||||
function roomCreateEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
"creator": "@daryl:alexandria.example.com",
|
||||
"m.federate": true,
|
||||
"predecessor": {
|
||||
event_id: "id_of_last_event",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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 { MatrixEvent } from "../../../src";
|
||||
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
};
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
sender: {
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
it("rejects membership with no expiry", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(6000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns active foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getActiveFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
// our clock would have been at 2000 at the creation time (our clock at event receive time - age)
|
||||
// (ie. the local clock is 1 second ahead of the servers' clocks)
|
||||
fakeEvent.localTimestamp = 2000;
|
||||
|
||||
// for simplicity's sake, we say that the event's age is zero
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(0);
|
||||
|
||||
membership = new CallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("converts expiry time into local clock", () => {
|
||||
// for sanity's sake, make sure the server-relative expiry time is what we expect
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(6000);
|
||||
// therefore the expiry time converted to our clock should be 1 second later
|
||||
expect(membership.getLocalExpiry()).toEqual(7000);
|
||||
});
|
||||
|
||||
it("calculates time until expiry", () => {
|
||||
jest.setSystemTime(2000);
|
||||
expect(membership.getMsUntilExpiry()).toEqual(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
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 { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, mockRTCEvent } from "./mocks";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
describe("MatrixRTCSession", () => {
|
||||
let client: MatrixClient;
|
||||
let sess: MatrixRTCSession | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
|
||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
if (sess) sess.stop();
|
||||
sess = undefined;
|
||||
});
|
||||
|
||||
it("Creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].callId).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], () => 10000);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const mockRoom = {
|
||||
roomId: randomString(8),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const mockRoom = {
|
||||
roomId: randomString(8),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no expires_ts", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
(expiredMembership.expires as number | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no scope", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.scope as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores anything that's not a room-scoped call (for now)", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
testMembership.scope = "m.user";
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
||||
});
|
||||
});
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// stop the timers
|
||||
sess!.leaveRoomSession();
|
||||
});
|
||||
|
||||
it("starts un-joined", () => {
|
||||
expect(sess!.isJoined()).toEqual(false);
|
||||
});
|
||||
|
||||
it("shows joined once join is called", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [{ type: "mock" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", () => {
|
||||
const sendStateEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renews membership event before expiry time", async () => {
|
||||
jest.useFakeTimers();
|
||||
let resolveFn: ((_roomId: string, _type: string, val: Record<string, any>) => void) | undefined;
|
||||
|
||||
const eventSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
r(val);
|
||||
};
|
||||
});
|
||||
try {
|
||||
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
const eventContent = await eventSentPromise;
|
||||
|
||||
// definitely should have renewed by 1 second before the expiry!
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, () => timeElapsed));
|
||||
|
||||
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
r(val);
|
||||
};
|
||||
});
|
||||
|
||||
sendStateEventMock.mockReset().mockImplementation(resolveFn);
|
||||
|
||||
jest.setSystemTime(Date.now() + timeElapsed);
|
||||
jest.advanceTimersByTime(timeElapsed);
|
||||
await eventReSentPromise;
|
||||
|
||||
expect(sendStateEventMock).toHaveBeenCalledWith(
|
||||
mockRoom.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000 * 2,
|
||||
foci_active: [{ type: "mock" }],
|
||||
created_ts: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an event at the time a membership event expires", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
let eventAge = 0;
|
||||
|
||||
const membership = Object.assign({}, membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membership], () => eventAge);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const membershipObject = sess.memberships[0];
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
eventAge = 61 * 1000 * 1000;
|
||||
jest.advanceTimersByTime(61 * 1000 * 1000);
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prunes expired memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
let eventAge = 0;
|
||||
|
||||
const mockRoom = makeMockRoom(
|
||||
[
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 1000,
|
||||
}),
|
||||
],
|
||||
() => eventAge,
|
||||
);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
// sanity check
|
||||
expect(sess.memberships).toHaveLength(1);
|
||||
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
|
||||
|
||||
eventAge = 10000;
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills in created_ts for other memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
}),
|
||||
]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 3600000,
|
||||
created_ts: 1000,
|
||||
},
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom } from "./mocks";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
|
||||
const memberships = [membershipTemplate];
|
||||
|
||||
const room1 = makeMockRoom(memberships);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
memberships.splice(0, 1);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("")[0];
|
||||
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 { EventType, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
|
||||
export function makeMockRoom(
|
||||
memberships: CallMembershipData[],
|
||||
getLocalAge: (() => number) | undefined = undefined,
|
||||
): Room {
|
||||
const roomId = randomString(8);
|
||||
return {
|
||||
roomId: roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(makeMockRoomState(memberships, roomId, getLocalAge)),
|
||||
}),
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
|
||||
return {
|
||||
getStateEvents: (_: string, stateKey: string) => {
|
||||
const event = mockRTCEvent(memberships, roomId, getLocalAge);
|
||||
|
||||
if (stateKey !== undefined) return event;
|
||||
return [event];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(
|
||||
memberships: CallMembershipData[],
|
||||
roomId: string,
|
||||
getLocalAge: (() => number) | undefined,
|
||||
): MatrixEvent {
|
||||
const getLocalAgeFn = getLocalAge ?? (() => 10);
|
||||
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
memberships: memberships,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: getLocalAgeFn,
|
||||
localTimestamp: Date.now(),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
@@ -308,4 +308,25 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore thread relation on state events", async () => {
|
||||
const stateEvent = new MatrixEvent({
|
||||
event_id: "$event_id",
|
||||
type: "some_state_event",
|
||||
content: {
|
||||
"foo": "bar",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread_id",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread_id",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
state_key: "",
|
||||
});
|
||||
|
||||
expect(stateEvent.isState()).toBeTruthy();
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { mocked } from "jest-mock";
|
||||
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
@@ -43,6 +43,7 @@ describe("Thread", () => {
|
||||
const myUserId = "@bob:example.org";
|
||||
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { timelineSupport: false });
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -300,6 +301,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -354,6 +356,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -405,6 +408,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { Method } from "../../../src";
|
||||
import * as crypto from "../../../src/crypto/crypto";
|
||||
import { logger } from "../../../src/logger";
|
||||
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");
|
||||
|
||||
// save for resetting mocks
|
||||
const realSubtleCrypto = crypto.subtleCrypto;
|
||||
|
||||
describe("oidc authorization", () => {
|
||||
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(() => {
|
||||
// @ts-ignore reset any ugly mocking we did
|
||||
crypto.subtleCrypto = realSubtleCrypto;
|
||||
});
|
||||
|
||||
it("should generate authorization params", () => {
|
||||
const result = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
|
||||
expect(result.redirectUri).toEqual(baseUrl);
|
||||
|
||||
// random strings
|
||||
expect(result.state.length).toEqual(8);
|
||||
expect(result.nonce.length).toEqual(8);
|
||||
expect(result.codeVerifier.length).toEqual(64);
|
||||
|
||||
const expectedScope =
|
||||
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:";
|
||||
expect(result.scope.startsWith(expectedScope)).toBeTruthy();
|
||||
// deviceId of 10 characters is appended to the device scope
|
||||
expect(result.scope.length).toEqual(expectedScope.length + 10);
|
||||
});
|
||||
|
||||
describe("generateAuthorizationUrl()", () => {
|
||||
it("should generate url with correct parameters", async () => {
|
||||
// test the no crypto case here
|
||||
// @ts-ignore mocking
|
||||
crypto.subtleCrypto = undefined;
|
||||
|
||||
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
const authUrl = new URL(
|
||||
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
|
||||
);
|
||||
|
||||
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");
|
||||
expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope);
|
||||
expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state);
|
||||
expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce);
|
||||
|
||||
// crypto not available, plain text code_challenge is used
|
||||
expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"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;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
const homeserverUrl = "https://server.org/";
|
||||
const identityServerUrl = "https://id.org/";
|
||||
const nonce = "test-nonce";
|
||||
const redirectUri = baseUrl;
|
||||
const code = "auth_code_xyz";
|
||||
const validBearerTokenResponse = {
|
||||
token_type: "Bearer",
|
||||
access_token: "test_access_token",
|
||||
refresh_token: "test_refresh_token",
|
||||
id_token: "valid.id.token",
|
||||
expires_in: 300,
|
||||
};
|
||||
|
||||
const metadata = mockOpenIdConfiguration();
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
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.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
|
||||
fetchMock.get(`${metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
mockSessionStorage({});
|
||||
|
||||
mocked(jwtDecode).mockReturnValue(validDecodedIdToken);
|
||||
});
|
||||
|
||||
it("should make correct request to the token endpoint", async () => {
|
||||
const state = await setupState();
|
||||
const codeVerifier = getValueFromStorage(state, "code_verifier");
|
||||
await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
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 state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
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,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...tokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
});
|
||||
|
||||
it("should throw when state is not found in storage", async () => {
|
||||
// don't setup sessionStorage with expected state
|
||||
const state = "abc123";
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
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(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...invalidBearerTokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
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, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidIdToken),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMockJest from "fetch-mock-jest";
|
||||
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
import { registerOidcClient } from "../../../src/oidc/register";
|
||||
|
||||
describe("registerOidcClient()", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const registrationEndpoint = "https://auth.com/register";
|
||||
const clientName = "Element";
|
||||
const baseUrl = "https://just.testing";
|
||||
const dynamicClientId = "xyz789";
|
||||
|
||||
const delegatedAuthConfig = {
|
||||
issuer,
|
||||
registrationEndpoint,
|
||||
authorizationEndpoint: issuer + "auth",
|
||||
tokenEndpoint: issuer + "token",
|
||||
};
|
||||
beforeEach(() => {
|
||||
fetchMockJest.mockClear();
|
||||
fetchMockJest.resetBehavior();
|
||||
});
|
||||
|
||||
it("should make correct request to register client", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
|
||||
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_name: clientName,
|
||||
client_uri: baseUrl,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
redirect_uris: [baseUrl],
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when registration request fails", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 500,
|
||||
});
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationFailed,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when registration response is invalid", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
// no clientId in response
|
||||
body: "{}",
|
||||
});
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
});
|
||||
+119
-29
@@ -14,13 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { M_AUTHENTICATION } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
validateIdToken,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "../../../src/oidc/validate";
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
|
||||
jest.mock("jwt-decode");
|
||||
|
||||
describe("validateWellKnownAuthentication()", () => {
|
||||
const baseWk = {
|
||||
@@ -29,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
},
|
||||
};
|
||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported);
|
||||
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||
@@ -39,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: { url: "test.com" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||
@@ -50,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: { url: "test" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is false", () => {
|
||||
@@ -61,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: false,
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses stable m.authentication", () => {
|
||||
@@ -72,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",
|
||||
});
|
||||
@@ -85,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: "test.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
@@ -98,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", () => {
|
||||
@@ -123,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"],
|
||||
@@ -137,7 +136,7 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
it("should throw OP support error when wellKnown is not an object", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown([]);
|
||||
}).toThrow(OidcDiscoveryError.OpSupport);
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
|
||||
});
|
||||
|
||||
@@ -148,11 +147,9 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
authorization_endpoint: undefined,
|
||||
response_types_supported: [],
|
||||
});
|
||||
}).toThrow(OidcDiscoveryError.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.",
|
||||
);
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
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", () => {
|
||||
@@ -194,6 +191,99 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
...validWk,
|
||||
[key]: value,
|
||||
};
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport);
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateIdToken()", () => {
|
||||
const nonce = "test-nonce";
|
||||
const issuer = "https://auth.org/issuer";
|
||||
const clientId = "test-client-id";
|
||||
const idToken = "test-id-token";
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 5555,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
iss: issuer,
|
||||
};
|
||||
beforeEach(() => {
|
||||
mocked(jwtDecode).mockClear().mockReturnValue(validDecodedIdToken);
|
||||
|
||||
jest.spyOn(logger, "error").mockClear();
|
||||
});
|
||||
|
||||
it("should throw when idToken is falsy", () => {
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when idToken cannot be decoded", () => {
|
||||
mocked(jwtDecode).mockImplementation(() => {
|
||||
throw new Error("oh no!");
|
||||
});
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when issuer does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
iss: "https://badissuer.com",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid issuer"));
|
||||
});
|
||||
|
||||
it("should throw when audience does not include clientId", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: "qwerty,uiop,asdf",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when audience includes clientId and other audiences", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: `${clientId},uiop,asdf`,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when nonce does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
nonce: "something else",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid nonce"));
|
||||
});
|
||||
|
||||
it("should throw when token does not have an expiry", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
exp: undefined,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should throw when token is expired", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
// expired in the past
|
||||
exp: Date.now() / 1000 - 777,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should not throw for a valid id token", () => {
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,7 +657,7 @@ describe("NotificationService", function () {
|
||||
content: {
|
||||
"body": "",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc3952.mentions": {},
|
||||
"m.mentions": {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,9 +70,7 @@ describe("RoomState", function () {
|
||||
user: userA,
|
||||
room: roomId,
|
||||
event: true,
|
||||
content: {
|
||||
creator: userA,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
+200
-37
@@ -228,7 +228,7 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
describe("getCreator", () => {
|
||||
it("should return the creator from m.room.create", function () {
|
||||
it("should return the sender from m.room.create", function () {
|
||||
// @ts-ignore - mocked doesn't handle overloads sanely
|
||||
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
|
||||
if (type === EventType.RoomCreate && key === "") {
|
||||
@@ -239,7 +239,7 @@ describe("Room", function () {
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
creator: userA,
|
||||
creator: userB, // The creator field was dropped in room version 11 but a malicious client might still send it
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -247,6 +247,24 @@ describe("Room", function () {
|
||||
const roomCreator = room.getCreator();
|
||||
expect(roomCreator).toStrictEqual(userA);
|
||||
});
|
||||
|
||||
it("should return null if the sender is undefined", function () {
|
||||
// @ts-ignore - mocked doesn't handle overloads sanely
|
||||
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
|
||||
if (type === EventType.RoomCreate && key === "") {
|
||||
return utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomCreate,
|
||||
skey: "",
|
||||
room: roomId,
|
||||
user: undefined,
|
||||
content: {},
|
||||
});
|
||||
}
|
||||
});
|
||||
const roomCreator = room.getCreator();
|
||||
expect(roomCreator).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvatarUrl", function () {
|
||||
@@ -1184,6 +1202,21 @@ describe("Room", function () {
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual("Empty room");
|
||||
});
|
||||
|
||||
it("emits an update event", function () {
|
||||
const spy = jest.fn();
|
||||
const summary = {
|
||||
"m.heroes": [],
|
||||
"m.invited_member_count": 1,
|
||||
};
|
||||
|
||||
room.once(RoomEvent.Summary, spy);
|
||||
|
||||
room.setSummary(summary);
|
||||
room.recalculate();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(summary);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room.recalculate => Room Name", function () {
|
||||
@@ -1420,6 +1453,87 @@ describe("Room", function () {
|
||||
}
|
||||
|
||||
describe("addReceipt", function () {
|
||||
describe("resets the unread count", () => {
|
||||
const event1 = utils.mkMessage({ room: roomId, user: userA, msg: "1", event: true });
|
||||
const event2 = utils.mkMessage({ room: roomId, user: userA, msg: "2", event: true });
|
||||
|
||||
it("should reset the unread count when our non-synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for me for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is set to 0
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(0);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when someone else's receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for someone else for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userB, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is unchanged because it's not my receipt
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when our non-synthetic receipt points to an earlier event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for me for an earlier event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event1.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is unchanged because it wasn't the latest event
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when our a synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a synthetic receipt for me for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt, true);
|
||||
|
||||
// Then the count is unchanged because the receipt was synthetic
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
});
|
||||
|
||||
it("should store the receipt so it can be obtained via getReceiptsForEvent", function () {
|
||||
const ts = 13787898424;
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
|
||||
@@ -2556,7 +2670,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 +2697,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 +2881,7 @@ describe("Room", function () {
|
||||
"m.relations": {
|
||||
"m.thread": {
|
||||
latest_event: threadResponse2.event,
|
||||
count: 2,
|
||||
count: 1,
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
@@ -2809,11 +2925,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 +2964,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 +2982,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 +2996,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 +3024,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 +3068,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 +3133,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 +3227,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;
|
||||
};
|
||||
@@ -3420,12 +3585,10 @@ describe("Room", function () {
|
||||
|
||||
function roomCreateEvent(newRoomId: string, predecessorRoomId: string | null): MatrixEvent {
|
||||
const content: {
|
||||
creator: string;
|
||||
["m.federate"]: boolean;
|
||||
room_version: string;
|
||||
predecessor: { event_id: string; room_id: string } | undefined;
|
||||
} = {
|
||||
"creator": "@daryl:alexandria.example.com",
|
||||
"predecessor": undefined,
|
||||
"m.federate": true,
|
||||
"room_version": "9",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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,11 +26,12 @@ 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";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, MatrixHttpApi, UIAuthCallback } from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
@@ -161,7 +162,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");
|
||||
@@ -218,4 +219,40 @@ describe("OutgoingRequestProcessor", () => {
|
||||
await Promise.all([processor.makeOutgoingRequest(outgoingRequest), markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
|
||||
});
|
||||
|
||||
it("does not explode if the OlmMachine is stopped while the request is in flight", async () => {
|
||||
// we use a real olm machine for this test
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(
|
||||
new RustSdkCryptoJs.UserId("@alice:example.com"),
|
||||
new RustSdkCryptoJs.DeviceId("TEST_DEVICE"),
|
||||
);
|
||||
|
||||
const authRequestResultDefer = defer<string>();
|
||||
|
||||
const authRequestCalledPromise = new Promise<void>((resolve) => {
|
||||
const mockHttpApi = {
|
||||
authedRequest: async () => {
|
||||
resolve();
|
||||
return await authRequestResultDefer.promise;
|
||||
},
|
||||
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
|
||||
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
|
||||
});
|
||||
|
||||
// build a request
|
||||
const request = olmMachine.queryKeysForUsers([new RustSdkCryptoJs.UserId("@bob:example.com")]);
|
||||
const result = processor.makeOutgoingRequest(request);
|
||||
|
||||
// wait for the HTTP request to be made
|
||||
await authRequestCalledPromise;
|
||||
|
||||
// while the HTTP request is in flight, the OlmMachine gets stopped.
|
||||
olmMachine.close();
|
||||
|
||||
// the HTTP request completes...
|
||||
authRequestResultDefer.resolve("{}");
|
||||
|
||||
// ... and `makeOutgoingRequest` resolves satisfactorily
|
||||
await result;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,77 +14,62 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import {
|
||||
CryptoEvent,
|
||||
Device,
|
||||
DeviceVerification,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IHttpOpts,
|
||||
IToDeviceEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixHttpApi,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
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 } from "../../../src/crypto-api";
|
||||
import {
|
||||
CryptoCallbacks,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
VerificationRequest,
|
||||
} from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
|
||||
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();
|
||||
});
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
beforeEach(
|
||||
async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
},
|
||||
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
|
||||
10000,
|
||||
);
|
||||
|
||||
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) => {
|
||||
@@ -93,11 +78,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];
|
||||
|
||||
@@ -145,13 +130,72 @@ describe("RustCrypto", () => {
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
const toDeviceEvent = {
|
||||
type: "m.key.verification.request",
|
||||
content: {
|
||||
from_device: "testDeviceId",
|
||||
methods: ["m.sas.v1"],
|
||||
transaction_id: "testTxn",
|
||||
timestamp: Date.now() - 1000,
|
||||
},
|
||||
sender: "@user:id",
|
||||
};
|
||||
|
||||
const onEvent = jest.fn();
|
||||
rustCrypto.on(CryptoEvent.VerificationRequestReceived, onEvent);
|
||||
await rustCrypto.preprocessToDeviceMessages([toDeviceEvent]);
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [req]: [VerificationRequest] = onEvent.mock.lastCall;
|
||||
expect(req.transactionId).toEqual("testTxn");
|
||||
});
|
||||
});
|
||||
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
it("getCrossSigningKeyId when there is no cross signing keys", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus", () => {
|
||||
it("returns sensible values on a default client", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, secretStorage);
|
||||
|
||||
const result = await rustCrypto.getCrossSigningStatus();
|
||||
|
||||
expect(secretStorage.isStored).toHaveBeenCalledWith("m.cross_signing.master");
|
||||
expect(result).toEqual({
|
||||
privateKeysCachedLocally: {
|
||||
masterKey: false,
|
||||
selfSigningKey: false,
|
||||
userSigningKey: false,
|
||||
},
|
||||
privateKeysInSecretStorage: false,
|
||||
publicKeysOnDevice: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if `stop` is called mid-call", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, secretStorage);
|
||||
|
||||
// start the call off
|
||||
const result = rustCrypto.getCrossSigningStatus();
|
||||
|
||||
// call `.stop`
|
||||
rustCrypto.stop();
|
||||
|
||||
// getCrossSigningStatus should abort
|
||||
await expect(result).rejects.toEqual(new Error("MatrixClient has been stopped"));
|
||||
});
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning delegates to CrossSigningIdentity", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const mockCrossSigningIdentity = {
|
||||
@@ -238,6 +282,31 @@ describe("RustCrypto", () => {
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledWith(testReq);
|
||||
});
|
||||
|
||||
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
|
||||
// the first call to `outgoingMessages` will return a promise which blocks for a while
|
||||
const firstOutgoingRequestsDefer = defer<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
|
||||
|
||||
// the second will return a KeysQueryRequest.
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
outgoingRequestQueue.push([testReq]);
|
||||
|
||||
// the first sync completes, triggering the first call to `outgoingMessages`
|
||||
rustCrypto.onSyncCompleted({});
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a second /sync completes before the first call to `outgoingRequests` completes. It shouldn't trigger
|
||||
// a second call immediately, but should queue one up.
|
||||
rustCrypto.onSyncCompleted({});
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
|
||||
|
||||
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
|
||||
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
|
||||
firstOutgoingRequestsDefer.resolve([]);
|
||||
await awaitCallToMakeOutgoingRequest();
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops looping when stop() is called", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
|
||||
@@ -311,6 +380,111 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".getEncryptionInfoForEvent", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(() => {
|
||||
olmMachine = {
|
||||
getRoomEventEncryptionInfo: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function makeEncryptedEvent(): Promise<MatrixEvent> {
|
||||
const encryptedEvent = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "fake_alg" },
|
||||
room: "!room:id",
|
||||
});
|
||||
encryptedEvent.event.event_id = "$event:id";
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: () =>
|
||||
({
|
||||
clearEvent: { content: { body: "1234" } },
|
||||
} as unknown as IEventDecryptionResult),
|
||||
} as unknown as CryptoBackend;
|
||||
await encryptedEvent.attemptDecryption(mockCryptoBackend);
|
||||
return encryptedEvent;
|
||||
}
|
||||
|
||||
it("should handle unencrypted events", async () => {
|
||||
const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } });
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(event);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the event into the OlmMachine", async () => {
|
||||
const encryptedEvent = await makeEncryptedEvent();
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(encryptedEvent);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).toHaveBeenCalledTimes(1);
|
||||
const [passedEvent, passedRoom] = olmMachine.getRoomEventEncryptionInfo.mock.calls[0];
|
||||
expect(passedRoom.toString()).toEqual("!room:id");
|
||||
expect(JSON.parse(passedEvent)).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
event_id: "$event:id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RustSdkCryptoJs.ShieldColor.None, EventShieldColour.NONE],
|
||||
[RustSdkCryptoJs.ShieldColor.Grey, EventShieldColour.GREY],
|
||||
[RustSdkCryptoJs.ShieldColor.Red, EventShieldColour.RED],
|
||||
])("gets the right shield color (%i)", async (rustShield, expectedShield) => {
|
||||
const mockEncryptionInfo = {
|
||||
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: null }),
|
||||
} as unknown as RustSdkCryptoJs.EncryptionInfo;
|
||||
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(await makeEncryptedEvent());
|
||||
expect(mockEncryptionInfo.shieldState).toHaveBeenCalledWith(false);
|
||||
expect(res).not.toBe(null);
|
||||
expect(res!.shieldColour).toEqual(expectedShield);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
["Encrypted by an unverified user.", EventShieldReason.UNVERIFIED_IDENTITY],
|
||||
["Encrypted by a device not verified by its owner.", EventShieldReason.UNSIGNED_DEVICE],
|
||||
[
|
||||
"The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
],
|
||||
["Encrypted by an unknown or deleted device.", EventShieldReason.UNKNOWN_DEVICE],
|
||||
["bloop", EventShieldReason.UNKNOWN],
|
||||
])("gets the right shield reason (%s)", async (rustReason, expectedReason) => {
|
||||
// suppress the warning from the unknown shield reason
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const mockEncryptionInfo = {
|
||||
shieldState: jest
|
||||
.fn()
|
||||
.mockReturnValue({ color: RustSdkCryptoJs.ShieldColor.None, message: rustReason }),
|
||||
} as unknown as RustSdkCryptoJs.EncryptionInfo;
|
||||
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(await makeEncryptedEvent());
|
||||
expect(mockEncryptionInfo.shieldState).toHaveBeenCalledWith(false);
|
||||
expect(res).not.toBe(null);
|
||||
expect(res!.shieldReason).toEqual(expectedReason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get|setTrustCrossSignedDevices", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
@@ -330,6 +504,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>;
|
||||
@@ -373,29 +601,58 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID);
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false if there is no cross-signing identity", async () => {
|
||||
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(testData.SIGNED_CROSS_SIGNING_KEYS_DATA),
|
||||
);
|
||||
|
||||
// ... and we should now have cross-signing keys.
|
||||
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true if the user is untracked, downloadUncached is set at true and the cross-signing keys are available", async () => {
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
[testData.BOB_TEST_USER_ID]: {
|
||||
[testData.BOB_TEST_DEVICE_ID]: testData.BOB_SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
...testData.BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
});
|
||||
|
||||
await expect(rustCrypto.userHasCrossSigningKeys(testData.BOB_TEST_USER_ID, true)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if the user is unknown", async () => {
|
||||
await expect(rustCrypto.userHasCrossSigningKeys(testData.BOB_TEST_USER_ID)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRecoveryKeyFromPassphrase", () => {
|
||||
@@ -461,6 +718,87 @@ describe("RustCrypto", () => {
|
||||
expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true);
|
||||
rustCrypto.stop();
|
||||
});
|
||||
|
||||
describe("requestDeviceVerification", () => {
|
||||
it("throws an error if the device is unknown", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(() => rustCrypto.requestDeviceVerification(TEST_USER, "unknown")).rejects.toThrow(
|
||||
"Not a known device",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserVerificationStatus", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(() => {
|
||||
olmMachine = {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => {
|
||||
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
|
||||
expect(userVerificationStatus.isVerified()).toBeFalsy();
|
||||
expect(userVerificationStatus.isTofu()).toBeFalsy();
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy();
|
||||
expect(userVerificationStatus.wasCrossSigningVerified()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("returns a verified UserVerificationStatus when the UserIdentity is verified", async () => {
|
||||
olmMachine.getIdentity.mockResolvedValue({ isVerified: jest.fn().mockReturnValue(true) });
|
||||
|
||||
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
|
||||
expect(userVerificationStatus.isVerified()).toBeTruthy();
|
||||
expect(userVerificationStatus.isTofu()).toBeFalsy();
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(userVerificationStatus.wasCrossSigningVerified()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
@@ -474,5 +812,5 @@ async function makeTestRustCrypto(
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks);
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks, null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
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", () => {
|
||||
let request: RustVerificationRequest;
|
||||
let mockedInner: Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedInner = makeMockedInner();
|
||||
request = makeTestRequest(mockedInner);
|
||||
});
|
||||
|
||||
it("returns true for a created request", () => {
|
||||
expect(request.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for passive requests", () => {
|
||||
mockedInner.isPassive.mockReturnValue(true);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for completed requests", () => {
|
||||
mockedInner.phase.mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Done);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for cancelled requests", () => {
|
||||
mockedInner.phase.mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Cancelled);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeout", () => {
|
||||
it("passes through the result", () => {
|
||||
const mockedInner = makeMockedInner();
|
||||
const request = makeTestRequest(mockedInner);
|
||||
mockedInner.timeRemainingMillis.mockReturnValue(10_000);
|
||||
expect(request.timeout).toEqual(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startVerification", () => {
|
||||
let request: RustVerificationRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
request = makeTestRequest();
|
||||
});
|
||||
|
||||
it("does not permit methods other than SAS", async () => {
|
||||
await expect(request.startVerification("m.reciprocate.v1")).rejects.toThrow(
|
||||
"Unsupported verification method",
|
||||
);
|
||||
});
|
||||
|
||||
it("raises an error if starting verification does not produce a verifier", async () => {
|
||||
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
|
||||
"Still no verifier after startSas() call",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
outgoingRequestProcessor?: OutgoingRequestProcessor,
|
||||
): RustVerificationRequest {
|
||||
inner ??= makeMockedInner();
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
function makeMockedInner(): Mocked<RustSdkCryptoJs.VerificationRequest> {
|
||||
return {
|
||||
registerChangesCallback: jest.fn(),
|
||||
startSas: jest.fn(),
|
||||
phase: jest.fn().mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Created),
|
||||
isPassive: jest.fn().mockReturnValue(false),
|
||||
timeRemainingMillis: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
}
|
||||
@@ -278,7 +278,7 @@ describe("IndexedDBStore", () => {
|
||||
workerFactory: () => worker,
|
||||
});
|
||||
await store.startup();
|
||||
await expect(store.destroy()).resolves;
|
||||
await store.destroy();
|
||||
expect(terminate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { IJoinedRoom, ISyncResponse, SyncAccumulator } from "../../src/sync-accumulator";
|
||||
import {
|
||||
IJoinedRoom,
|
||||
IInvitedRoom,
|
||||
IKnockedRoom,
|
||||
IKnockState,
|
||||
ILeftRoom,
|
||||
IRoomEvent,
|
||||
IStateEvent,
|
||||
IStrippedState,
|
||||
ISyncResponse,
|
||||
SyncAccumulator,
|
||||
IInviteState,
|
||||
} from "../../src/sync-accumulator";
|
||||
import { IRoomSummary } from "../../src";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
|
||||
// The event body & unsigned object get frozen to assert that they don't get altered
|
||||
// by the impl
|
||||
@@ -95,6 +108,13 @@ describe("SyncAccumulator", function () {
|
||||
},
|
||||
},
|
||||
},
|
||||
knock: {
|
||||
"!knock": {
|
||||
knock_state: {
|
||||
events: [member("alice", "knock")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
sa.accumulate(res);
|
||||
@@ -287,6 +307,268 @@ describe("SyncAccumulator", function () {
|
||||
expect(sa.getJSON().accountData[0]).toEqual(acc2);
|
||||
});
|
||||
|
||||
it("should delete invite room when invite request is rejected", () => {
|
||||
const initInviteState: IInviteState = {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{
|
||||
"!invite:bar": {
|
||||
invite_state: initInviteState,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.invite["!invite:bar"].invite_state).toBe(initInviteState);
|
||||
|
||||
const rejectMemberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "bob",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "invite",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([rejectMemberEvent]);
|
||||
|
||||
// bob rejects invite
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!invite:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.invite["!invite:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accumulate knock state", () => {
|
||||
const initKnockState = {
|
||||
events: [member("alice", "knock")],
|
||||
};
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room 1",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content
|
||||
.name,
|
||||
).toEqual("Room 1");
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room 2",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content
|
||||
.name,
|
||||
).toEqual("Room 2");
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is approved", () => {
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// alice approves bob's knock request
|
||||
const inviteStateEvents = [
|
||||
{
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
},
|
||||
];
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{
|
||||
"!knock:bar": {
|
||||
invite_state: {
|
||||
events: inviteStateEvents,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
expect(sa.getJSON().roomsData.invite["!knock:bar"].invite_state.events).toEqual(inviteStateEvents);
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is cancelled by user himself", () => {
|
||||
// bob cancels his knock state
|
||||
const memberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "bob",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([memberEvent]);
|
||||
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// bob cancels his knock request
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!knock:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is denied by another user", () => {
|
||||
// alice denies bob knock state
|
||||
const memberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([memberEvent]);
|
||||
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// alice denies bob's knock request
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!knock:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accumulate read receipts", () => {
|
||||
const receipt1 = {
|
||||
type: "m.receipt",
|
||||
@@ -601,7 +883,12 @@ describe("SyncAccumulator", function () {
|
||||
});
|
||||
});
|
||||
|
||||
function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse {
|
||||
function syncSkeleton(
|
||||
joinObj: Partial<IJoinedRoom>,
|
||||
invite?: Record<string, IInvitedRoom>,
|
||||
leave?: Record<string, ILeftRoom>,
|
||||
knockObj?: Partial<IKnockedRoom>,
|
||||
): ISyncResponse {
|
||||
joinObj = joinObj || {};
|
||||
return {
|
||||
next_batch: "abc",
|
||||
@@ -609,10 +896,48 @@ function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse {
|
||||
join: {
|
||||
"!foo:bar": joinObj,
|
||||
},
|
||||
invite,
|
||||
leave,
|
||||
knock: knockObj
|
||||
? {
|
||||
"!knock:bar": knockObj,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
}
|
||||
|
||||
function leftRoomSkeleton(timelineEvents: Array<IRoomEvent | IStateEvent> = []): ILeftRoom {
|
||||
return {
|
||||
state: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: timelineEvents,
|
||||
prev_batch: "something",
|
||||
},
|
||||
account_data: {
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeKnockState(): IKnockState {
|
||||
return {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
member("bob", "knock"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function msg(localpart: string, text: string) {
|
||||
return {
|
||||
event_id: "$" + Math.random(),
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "../../test-utils/webrtc";
|
||||
import { CallFeed } from "../../../src/webrtc/callFeed";
|
||||
import { EventType, IContent, ISendEventResponse, MatrixEvent, Room } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
|
||||
const FAKE_ROOM_ID = "!foo:bar";
|
||||
const CALL_LIFETIME = 60000;
|
||||
@@ -1607,7 +1608,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()", () => {
|
||||
@@ -1812,4 +1813,30 @@ describe("Call", function () {
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit IceFailed error on the successor call if RTCPeerConnection throws", async () => {
|
||||
// @ts-ignore - writing to window as we are simulating browser edge-cases
|
||||
global.window = {};
|
||||
Object.defineProperty(global.window, "RTCPeerConnection", {
|
||||
get: () => {
|
||||
throw Error("Secure mode, naaah!");
|
||||
},
|
||||
});
|
||||
|
||||
const call = new MatrixCall({
|
||||
client: client.client,
|
||||
roomId: "!room_id",
|
||||
});
|
||||
const successor = new MatrixCall({
|
||||
client: client.client,
|
||||
roomId: "!room_id",
|
||||
});
|
||||
call.replacedBy(successor);
|
||||
|
||||
const prom = emitPromise(successor, CallEvent.Error);
|
||||
call.placeCall(true, true);
|
||||
|
||||
const err = await prom;
|
||||
expect(err.code).toBe(CallErrorCode.IceFailed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,8 @@ describe("Group Call Event Handler", function () {
|
||||
getMember: (userId: string) => (userId === FAKE_USER_ID ? mockMember : null),
|
||||
} as unknown as Room;
|
||||
|
||||
(mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom);
|
||||
mockClient.getRoom = jest.fn().mockReturnValue(mockRoom);
|
||||
mockClient.getFoci.mockReturnValue([{}]);
|
||||
});
|
||||
|
||||
describe("reacts to state changes", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+155
-13
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { IClientWellKnown } from "../client";
|
||||
|
||||
// disable lint because these are wire responses
|
||||
/* eslint-disable camelcase */
|
||||
@@ -79,19 +80,6 @@ export interface IIdentityProvider {
|
||||
brand?: IdentityProviderBrand | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
export interface ILoginParams {
|
||||
identifier?: object;
|
||||
password?: string;
|
||||
token?: string;
|
||||
device_id?: string;
|
||||
initial_device_display_name?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export enum SSOAction {
|
||||
/** The user intends to login to an existing account */
|
||||
LOGIN = "login",
|
||||
@@ -100,6 +88,160 @@ export enum SSOAction {
|
||||
REGISTER = "register",
|
||||
}
|
||||
|
||||
/**
|
||||
* A client can identify a user using their Matrix ID.
|
||||
* This can either be the fully qualified Matrix user ID, or just the localpart of the user ID.
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#matrix-user-id
|
||||
*/
|
||||
type UserLoginIdentifier = {
|
||||
type: "m.id.user";
|
||||
user: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A client can identify a user using a 3PID associated with the user’s account on the homeserver,
|
||||
* where the 3PID was previously associated using the /account/3pid API.
|
||||
* See the 3PID Types Appendix for a list of Third-party ID media.
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#third-party-id
|
||||
*/
|
||||
type ThirdPartyLoginIdentifier = {
|
||||
type: "m.id.thirdparty";
|
||||
medium: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A client can identify a user using a phone number associated with the user’s account,
|
||||
* where the phone number was previously associated using the /account/3pid API.
|
||||
* The phone number can be passed in as entered by the user; the homeserver will be responsible for canonicalising it.
|
||||
* If the client wishes to canonicalise the phone number,
|
||||
* then it can use the m.id.thirdparty identifier type with a medium of msisdn instead.
|
||||
*
|
||||
* The country is the two-letter uppercase ISO-3166-1 alpha-2 country code that the number in phone should be parsed as if it were dialled from.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#phone-number
|
||||
*/
|
||||
type PhoneLoginIdentifier = {
|
||||
type: "m.id.phone";
|
||||
country: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type SpecUserIdentifier = UserLoginIdentifier | ThirdPartyLoginIdentifier | PhoneLoginIdentifier;
|
||||
|
||||
/**
|
||||
* User Identifiers usable for login & user-interactive authentication.
|
||||
*
|
||||
* Extensibly allows more than Matrix specified identifiers.
|
||||
*/
|
||||
export type UserIdentifier =
|
||||
| SpecUserIdentifier
|
||||
| { type: Exclude<string, SpecUserIdentifier["type"]>; [key: string]: any };
|
||||
|
||||
/**
|
||||
* Request body for POST /login request
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3login
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
/**
|
||||
* The login type being used.
|
||||
*/
|
||||
type: "m.login.password" | "m.login.token" | string;
|
||||
/**
|
||||
* Third-party identifier for the user.
|
||||
* @deprecated in favour of `identifier`.
|
||||
*/
|
||||
address?: string;
|
||||
/**
|
||||
* ID of the client device.
|
||||
* If this does not correspond to a known client device, a new device will be created.
|
||||
* The given device ID must not be the same as a cross-signing key ID.
|
||||
* The server will auto-generate a device_id if this is not specified.
|
||||
*/
|
||||
device_id?: string;
|
||||
/**
|
||||
* Identification information for a user
|
||||
*/
|
||||
identifier?: UserIdentifier;
|
||||
/**
|
||||
* A display name to assign to the newly-created device.
|
||||
* Ignored if device_id corresponds to a known device.
|
||||
*/
|
||||
initial_device_display_name?: string;
|
||||
/**
|
||||
* When logging in using a third-party identifier, the medium of the identifier.
|
||||
* Must be `email`.
|
||||
* @deprecated in favour of `identifier`.
|
||||
*/
|
||||
medium?: "email";
|
||||
/**
|
||||
* Required when type is `m.login.password`. The user’s password.
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* If true, the client supports refresh tokens.
|
||||
*/
|
||||
refresh_token?: boolean;
|
||||
/**
|
||||
* Required when type is `m.login.token`. Part of Token-based login.
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* The fully qualified user ID or just local part of the user ID, to log in.
|
||||
* @deprecated in favour of identifier.
|
||||
*/
|
||||
user?: string;
|
||||
// Extensible
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Export for backwards compatibility
|
||||
export type ILoginParams = LoginRequest;
|
||||
|
||||
/**
|
||||
* Response body for POST /login request
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3login
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/**
|
||||
* An access token for the account.
|
||||
* This access token can then be used to authorize other requests.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* ID of the logged-in device.
|
||||
* Will be the same as the corresponding parameter in the request, if one was specified.
|
||||
*/
|
||||
device_id: string;
|
||||
/**
|
||||
* The fully-qualified Matrix ID for the account.
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* The lifetime of the access token, in milliseconds.
|
||||
* Once the access token has expired a new access token can be obtained by using the provided refresh token.
|
||||
* If no refresh token is provided, the client will need to re-log in to obtain a new access token.
|
||||
* If not given, the client can assume that the access token will not expire.
|
||||
*/
|
||||
expires_in_ms?: number;
|
||||
/**
|
||||
* A refresh token for the account.
|
||||
* This token can be used to obtain a new access token when it expires by calling the /refresh endpoint.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
/**
|
||||
* Optional client configuration provided by the server.
|
||||
* If present, clients SHOULD use the provided object to reconfigure themselves, optionally validating the URLs within.
|
||||
* This object takes the same form as the one returned from .well-known autodiscovery.
|
||||
*/
|
||||
well_known?: IClientWellKnown;
|
||||
/**
|
||||
* The server_name of the homeserver on which the account has been registered.
|
||||
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
|
||||
*/
|
||||
home_server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
|
||||
* `m.login.token` issuance request.
|
||||
|
||||
+2
-28
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IClearEvent } from "../models/event";
|
||||
import type { ISignatures } from "./signed";
|
||||
|
||||
export type OlmGroupSessionExtraData = {
|
||||
@@ -22,33 +21,8 @@ export type OlmGroupSessionExtraData = {
|
||||
sharedHistory?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of a (successful) call to {@link Crypto.decryptEvent}
|
||||
*/
|
||||
export interface IEventDecryptionResult {
|
||||
/**
|
||||
* The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
|
||||
*/
|
||||
clearEvent: IClearEvent;
|
||||
/**
|
||||
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
|
||||
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
|
||||
*/
|
||||
forwardingCurve25519KeyChain?: string[];
|
||||
/**
|
||||
* Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
|
||||
*/
|
||||
senderCurve25519Key?: string;
|
||||
/**
|
||||
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
|
||||
*/
|
||||
claimedEd25519Key?: string;
|
||||
untrusted?: boolean;
|
||||
/**
|
||||
* The sender doesn't authorize the unverified devices to decrypt his messages
|
||||
*/
|
||||
encryptedDisabledForUnverifiedDevices?: boolean;
|
||||
}
|
||||
// Backwards compatible re-export
|
||||
export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend";
|
||||
|
||||
interface Extensible {
|
||||
[key: string]: any;
|
||||
|
||||
Vendored
+16
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 { AuthDict } from "../interactive-auth";
|
||||
|
||||
/**
|
||||
* The request body of a call to `POST /_matrix/client/v3/register`.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3register
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
/**
|
||||
* Additional authentication information for the user-interactive authentication API.
|
||||
* Note that this information is not used to define how the registered user should be authenticated,
|
||||
* but is instead used to authenticate the register call itself.
|
||||
*/
|
||||
auth?: AuthDict;
|
||||
/**
|
||||
* The basis for the localpart of the desired Matrix ID.
|
||||
* If omitted, the homeserver MUST generate a Matrix ID local part.
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* The desired password for the account.
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* If true, the client supports refresh tokens.
|
||||
*/
|
||||
refresh_token?: boolean;
|
||||
/**
|
||||
* If true, an access_token and device_id should not be returned from this call, therefore preventing an automatic login.
|
||||
* Defaults to false.
|
||||
*/
|
||||
inhibit_login?: boolean;
|
||||
/**
|
||||
* A display name to assign to the newly-created device.
|
||||
* Ignored if device_id corresponds to a known device.
|
||||
*/
|
||||
initial_device_display_name?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
guest_access_token?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
x_show_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_email?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a successful call to `POST /_matrix/client/v3/register`.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3register
|
||||
*/
|
||||
export interface RegisterResponse {
|
||||
/**
|
||||
* The fully-qualified Matrix user ID (MXID) that has been registered.
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* An access token for the account.
|
||||
* This access token can then be used to authorize other requests.
|
||||
* Required if the inhibit_login option is false.
|
||||
*/
|
||||
access_token?: string;
|
||||
/**
|
||||
* ID of the registered device.
|
||||
* Will be the same as the corresponding parameter in the request, if one was specified.
|
||||
* Required if the inhibit_login option is false.
|
||||
*/
|
||||
device_id?: string;
|
||||
/**
|
||||
* The lifetime of the access token, in milliseconds.
|
||||
* Once the access token has expired a new access token can be obtained by using the provided refresh token.
|
||||
* If no refresh token is provided, the client will need to re-log in to obtain a new access token.
|
||||
* If not given, the client can assume that the access token will not expire.
|
||||
*
|
||||
* Omitted if the inhibit_login option is true.
|
||||
*/
|
||||
expires_in_ms?: number;
|
||||
/**
|
||||
* A refresh token for the account.
|
||||
* This token can be used to obtain a new access token when it expires by calling the /refresh endpoint.
|
||||
*
|
||||
* Omitted if the inhibit_login option is true.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
/**
|
||||
* The server_name of the homeserver on which the account has been registered.
|
||||
*
|
||||
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
|
||||
*/
|
||||
home_server?: string;
|
||||
}
|
||||
+14
-1
@@ -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;
|
||||
/**
|
||||
@@ -176,7 +188,8 @@ export interface IAddThreePidOnlyBody {
|
||||
export interface IBindThreePidBody {
|
||||
client_secret: string;
|
||||
id_server: string;
|
||||
id_access_token: string;
|
||||
// Some older identity servers have no auth enabled
|
||||
id_access_token: string | null;
|
||||
sid: string;
|
||||
}
|
||||
|
||||
|
||||
+89
-13
@@ -15,15 +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 { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
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
|
||||
@@ -46,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 {
|
||||
@@ -54,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,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);
|
||||
|
||||
/**
|
||||
@@ -181,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;
|
||||
|
||||
@@ -192,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,
|
||||
@@ -266,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)
|
||||
@@ -275,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
|
||||
@@ -288,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,
|
||||
@@ -297,7 +335,7 @@ export class AutoDiscovery {
|
||||
|
||||
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
|
||||
logger.error("Failed to fetch issuer openid configuration");
|
||||
throw new Error(OidcDiscoveryError.General);
|
||||
throw new Error(OidcError.General);
|
||||
}
|
||||
|
||||
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
|
||||
@@ -310,15 +348,53 @@ export class AutoDiscovery {
|
||||
};
|
||||
return delegatedAuthConfig;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message as unknown as OidcDiscoveryError;
|
||||
const errorType = Object.values(OidcDiscoveryError).includes(errorMessage)
|
||||
? errorMessage
|
||||
: OidcDiscoveryError.General;
|
||||
const errorMessage = (error as Error).message as unknown as OidcError;
|
||||
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
|
||||
|
||||
const state =
|
||||
errorType === OidcDiscoveryError.NotSupported
|
||||
? AutoDiscoveryAction.IGNORE
|
||||
: AutoDiscoveryAction.FAIL_ERROR;
|
||||
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
|
||||
|
||||
return {
|
||||
state,
|
||||
error: errorType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
+359
-363
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,18 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { IClearEvent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
import { IMegolmSessionData } from "../@types/crypto";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
/**
|
||||
@@ -45,9 +48,9 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
/**
|
||||
* Get the verification level for a given user
|
||||
*
|
||||
* TODO: define this better
|
||||
*
|
||||
* @param userId - user to be checked
|
||||
*
|
||||
* @deprecated Superceded by {@link CryptoApi#getUserVerificationStatus}.
|
||||
*/
|
||||
checkUserTrust(userId: string): UserTrustLevel;
|
||||
|
||||
@@ -69,7 +72,7 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @returns a promise which resolves once we have finished decrypting.
|
||||
* Rejects with an error if there is a problem decrypting the event.
|
||||
*/
|
||||
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
|
||||
decryptEvent(event: MatrixEvent): Promise<EventDecryptionResult>;
|
||||
|
||||
/**
|
||||
* Get information about the encryption of an event
|
||||
@@ -86,11 +89,31 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @param userId - the user ID to get the cross-signing info for.
|
||||
*
|
||||
* @returns the cross signing information for the user.
|
||||
* @deprecated Prefer {@link CryptoApi#userHasCrossSigningKeys}
|
||||
*/
|
||||
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>;
|
||||
|
||||
/**
|
||||
* Get a backup decryptor capable of decrypting megolm session data encrypted with the given backup information.
|
||||
* @param backupInfo - The backup information
|
||||
* @param privKey - The private decryption key.
|
||||
*/
|
||||
getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor>;
|
||||
}
|
||||
|
||||
/** 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 +169,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 +183,74 @@ export interface OnSyncCompletedData {
|
||||
*/
|
||||
catchingUp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}.
|
||||
*/
|
||||
export interface CheckOwnCrossSigningTrustOpts {
|
||||
allowPrivateKeyRequests?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a (successful) call to {@link CryptoBackend.decryptEvent}
|
||||
*/
|
||||
export interface EventDecryptionResult {
|
||||
/**
|
||||
* The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
|
||||
*/
|
||||
clearEvent: IClearEvent;
|
||||
/**
|
||||
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
|
||||
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
|
||||
*/
|
||||
forwardingCurve25519KeyChain?: string[];
|
||||
/**
|
||||
* Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
|
||||
*/
|
||||
senderCurve25519Key?: string;
|
||||
/**
|
||||
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
|
||||
*/
|
||||
claimedEd25519Key?: string;
|
||||
/**
|
||||
* Whether the keys for this event have been received via an unauthenticated source (eg via key forwards, or
|
||||
* restored from backup)
|
||||
*/
|
||||
untrusted?: boolean;
|
||||
/**
|
||||
* The sender doesn't authorize the unverified devices to decrypt his messages
|
||||
*/
|
||||
encryptedDisabledForUnverifiedDevices?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for decrypting megolm session data retrieved from a remote backup.
|
||||
* The result of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
export interface BackupDecryptor {
|
||||
/**
|
||||
* Whether keys retrieved from this backup can be trusted.
|
||||
*
|
||||
* Depending on the backup algorithm, keys retrieved from the backup can be trusted or not.
|
||||
* If false, keys retrieved from the backup must be considered unsafe (authenticity cannot be guaranteed).
|
||||
* It could be by design (deniability) or for some technical reason (eg asymmetric encryption).
|
||||
*/
|
||||
readonly sourceTrusted: boolean;
|
||||
|
||||
/**
|
||||
*
|
||||
* Decrypt megolm session data retrieved from backup.
|
||||
*
|
||||
* @param ciphertexts - a Record of sessionId to session data.
|
||||
*
|
||||
* @returns An array of decrypted `IMegolmSessionData`
|
||||
*/
|
||||
decryptSessions(ciphertexts: Record<string, KeyBackupSession>): Promise<IMegolmSessionData[]>;
|
||||
|
||||
/**
|
||||
* Free any resources held by this decryptor.
|
||||
*
|
||||
* Should be called once the decryptor is no longer needed.
|
||||
*/
|
||||
free(): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base64 encoding and decoding utility for crypo.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
+2
-2
@@ -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("#");
|
||||
|
||||
+270
-16
@@ -20,7 +20,9 @@ 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";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -37,16 +39,6 @@ export interface CryptoApi {
|
||||
*/
|
||||
globalBlacklistUnverifiedDevices: boolean;
|
||||
|
||||
/**
|
||||
* Checks if the user has previously published cross-signing keys
|
||||
*
|
||||
* This means downloading the devicelist for the user and checking if the list includes
|
||||
* the cross-signing pseudo-device.
|
||||
*
|
||||
* @returns true if the user has previously published cross-signing keys
|
||||
*/
|
||||
userHasCrossSigningKeys(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
* send, in order to speed up sending of the message.
|
||||
@@ -87,6 +79,22 @@ export interface CryptoApi {
|
||||
*/
|
||||
importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if the given user has published cross-signing keys.
|
||||
*
|
||||
* - If the user is tracked, a `/keys/query` request is made to update locally the cross signing keys.
|
||||
* - If the user is not tracked locally and downloadUncached is set to true,
|
||||
* a `/keys/query` request is made to the server to retrieve the cross signing keys.
|
||||
* - Otherwise, return false
|
||||
*
|
||||
* @param userId - the user ID to check. Defaults to the local user.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case `false` will be returned for such users.
|
||||
*
|
||||
* @returns true if the cross signing keys are available.
|
||||
*/
|
||||
userHasCrossSigningKeys(userId?: string, downloadUncached?: boolean): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
@@ -125,6 +133,14 @@ export interface CryptoApi {
|
||||
*/
|
||||
getTrustCrossSignedDevices(): boolean;
|
||||
|
||||
/**
|
||||
* Get the verification status of a given user.
|
||||
*
|
||||
* @param userId - The ID of the user to check.
|
||||
*
|
||||
*/
|
||||
getUserVerificationStatus(userId: string): Promise<UserVerificationStatus>;
|
||||
|
||||
/**
|
||||
* Get the verification status of a given device.
|
||||
*
|
||||
@@ -136,6 +152,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
|
||||
@@ -147,6 +179,8 @@ export interface CryptoApi {
|
||||
* return true.
|
||||
*
|
||||
* @returns True if cross-signing is ready to be used on this device
|
||||
*
|
||||
* @throws May throw {@link ClientStoppedError} if the `MatrixClient` is stopped before or during the call.
|
||||
*/
|
||||
isCrossSigningReady(): Promise<boolean>;
|
||||
|
||||
@@ -192,12 +226,17 @@ export interface CryptoApi {
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Bootstrap the secret storage by creating a new secret storage key and store it in the secret storage.
|
||||
* Bootstrap the secret storage by creating a new secret storage key, add it in the secret storage and
|
||||
* store the cross signing keys in the secret storage.
|
||||
*
|
||||
* - Do nothing if an AES key is already stored in the secret storage and `setupNewKeyBackup` is not set;
|
||||
* - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`.
|
||||
* Only if `setupNewSecretStorage` is set or if there is no AES key in the secret storage
|
||||
* - Store this key in the secret storage and set it as the default key.
|
||||
* - Call `cryptoCallbacks.cacheSecretStorageKey` if provided.
|
||||
* - Store the cross signing keys in the secret storage if
|
||||
* - the cross signing is ready
|
||||
* - a new key was created during the previous step
|
||||
* - or the secret storage already contains the cross signing keys
|
||||
*
|
||||
* @param opts - Options object.
|
||||
*/
|
||||
@@ -206,7 +245,10 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Get the status of our cross-signing keys.
|
||||
*
|
||||
* @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and whether the private keys are in secret storage.
|
||||
* @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and
|
||||
* whether the private keys are in secret storage.
|
||||
*
|
||||
* @throws May throw {@link ClientStoppedError} if the `MatrixClient` is stopped before or during the call.
|
||||
*/
|
||||
getCrossSigningStatus(): Promise<CrossSigningStatus>;
|
||||
|
||||
@@ -224,6 +266,16 @@ export interface CryptoApi {
|
||||
*/
|
||||
createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey>;
|
||||
|
||||
/**
|
||||
* Get information about the encryption of the given event.
|
||||
*
|
||||
* @param event - the event to get information for
|
||||
*
|
||||
* @returns `null` if the event is not encrypted, or has not (yet) been successfully decrypted. Otherwise, an
|
||||
* object with information about the encryption of the event.
|
||||
*/
|
||||
getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Device/User verification
|
||||
@@ -245,13 +297,39 @@ 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.
|
||||
*
|
||||
* If a verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
* This is normally used when the current device is new, and we want to ask another of our devices to cross-sign.
|
||||
*
|
||||
* If an all-devices verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
|
||||
* MatrixClient.
|
||||
*
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
@@ -260,7 +338,13 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Request an interactive verification with the given device.
|
||||
*
|
||||
* If a verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
* This is normally used on one of our own devices, when the current device is already cross-signed, and we want to
|
||||
* validate another device.
|
||||
*
|
||||
* If a verification for this user/device is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
|
||||
* MatrixClient.
|
||||
*
|
||||
* @param userId - ID of the owner of the device to verify
|
||||
* @param deviceId - ID of the device to verify
|
||||
@@ -268,6 +352,73 @@ 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>;
|
||||
|
||||
/**
|
||||
* Creates a new key backup version.
|
||||
*
|
||||
* If there are existing backups they will be replaced.
|
||||
*
|
||||
* The decryption key will be saved in Secret Storage (the {@link SecretStorageCallbacks.getSecretStorageKey} Crypto
|
||||
* callback will be called)
|
||||
* and the backup engine will be started.
|
||||
*/
|
||||
resetKeyBackup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes the given key backup.
|
||||
*
|
||||
* @param version - The backup version to delete.
|
||||
*/
|
||||
deleteKeyBackupVersion(version: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +435,46 @@ export interface BootstrapCrossSigningOpts {
|
||||
authUploadDeviceSigningKeys?: UIAuthCallback<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the ways in which we trust a user
|
||||
*/
|
||||
export class UserVerificationStatus {
|
||||
public constructor(
|
||||
private readonly crossSigningVerified: boolean,
|
||||
private readonly crossSigningVerifiedBefore: boolean,
|
||||
private readonly tofu: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns true if this user is verified via any means
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return this.isCrossSigningVerified();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this user is verified via cross signing
|
||||
*/
|
||||
public isCrossSigningVerified(): boolean {
|
||||
return this.crossSigningVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if we ever verified this user before (at least for
|
||||
* the history of verifications observed by this device).
|
||||
*/
|
||||
public wasCrossSigningVerified(): boolean {
|
||||
return this.crossSigningVerifiedBefore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this user's key is trusted on first use
|
||||
*/
|
||||
public isTofu(): boolean {
|
||||
return this.tofu;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceVerificationStatus {
|
||||
/**
|
||||
* True if this device has been signed by its owner (and that signature verified).
|
||||
@@ -463,6 +654,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}
|
||||
*/
|
||||
@@ -474,5 +676,57 @@ export interface GeneratedSecretStorageKey {
|
||||
encodedPrivateKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type of {@link CryptoApi#getEncryptionInfoForEvent}.
|
||||
*/
|
||||
export interface EventEncryptionInfo {
|
||||
/** "Shield" to be shown next to this event representing its verification status */
|
||||
shieldColour: EventShieldColour;
|
||||
|
||||
/**
|
||||
* `null` if `shieldColour` is `EventShieldColour.NONE`; otherwise a reason code for the shield in `shieldColour`.
|
||||
*/
|
||||
shieldReason: EventShieldReason | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of shield to be shown for {@link EventEncryptionInfo#shieldColour}.
|
||||
*/
|
||||
export enum EventShieldColour {
|
||||
NONE,
|
||||
GREY,
|
||||
RED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason codes for {@link EventEncryptionInfo#shieldReason}.
|
||||
*/
|
||||
export enum EventShieldReason {
|
||||
/** An unknown reason from the crypto library (if you see this, it is a bug in matrix-js-sdk). */
|
||||
UNKNOWN,
|
||||
|
||||
/** "Encrypted by an unverified user." */
|
||||
UNVERIFIED_IDENTITY,
|
||||
|
||||
/** "Encrypted by a device not verified by its owner." */
|
||||
UNSIGNED_DEVICE,
|
||||
|
||||
/** "Encrypted by an unknown or deleted device." */
|
||||
UNKNOWN_DEVICE,
|
||||
|
||||
/**
|
||||
* "The authenticity of this encrypted message can't be guaranteed on this device."
|
||||
*
|
||||
* ie: the key has been forwarded, or retrieved from an insecure backup.
|
||||
*/
|
||||
AUTHENTICITY_NOT_GUARANTEED,
|
||||
|
||||
/**
|
||||
* The (deprecated) sender_key field in the event does not match the Ed25519 key of the device that sent us the
|
||||
* decryption keys.
|
||||
*/
|
||||
MISMATCHED_SENDER_KEY,
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
export * from "./crypto-api/keybackup";
|
||||
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISigned } from "../@types/signed";
|
||||
import { IEncryptedPayload } from "../crypto/aes";
|
||||
|
||||
export interface Curve25519AuthData {
|
||||
public_key: string;
|
||||
@@ -31,7 +32,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 +44,46 @@ 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;
|
||||
}
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface KeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> {
|
||||
first_message_index: number;
|
||||
forwarded_count: number;
|
||||
is_verified: boolean;
|
||||
session_data: T;
|
||||
}
|
||||
|
||||
export interface KeyBackupRoomSessions {
|
||||
[sessionId: string]: KeyBackupSession;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface VerificationRequest
|
||||
* Cancels the request, sending a cancellation to the other party
|
||||
*
|
||||
* @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code`
|
||||
* (defaults to `m.user`).
|
||||
* (defaults to `m.user`). **Deprecated**: this parameter is ignored by the Rust cryptography implementation.
|
||||
*
|
||||
* @returns Promise which resolves when the event has been sent.
|
||||
*/
|
||||
@@ -128,9 +128,35 @@ export interface VerificationRequest
|
||||
* @param targetDevice - details of where to send the request to.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*
|
||||
* @deprecated Use {@link VerificationRequest#startVerification} instead.
|
||||
*/
|
||||
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -141,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.
|
||||
@@ -274,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.
|
||||
*/
|
||||
@@ -289,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`.
|
||||
|
||||
@@ -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";
|
||||
@@ -31,7 +30,10 @@ import { ICryptoCallbacks } from ".";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { DeviceVerificationStatus } from "../crypto-api";
|
||||
import { DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
|
||||
|
||||
// backwards-compatibility re-exports
|
||||
export { UserTrustLevel };
|
||||
|
||||
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
|
||||
|
||||
@@ -245,7 +247,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 +296,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) {
|
||||
@@ -588,46 +590,6 @@ export enum CrossSigningLevel {
|
||||
SELF_SIGNING = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the ways in which we trust a user
|
||||
*/
|
||||
export class UserTrustLevel {
|
||||
public constructor(
|
||||
private readonly crossSigningVerified: boolean,
|
||||
private readonly crossSigningVerifiedBefore: boolean,
|
||||
private readonly tofu: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns true if this user is verified via any means
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return this.isCrossSigningVerified();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this user is verified via cross signing
|
||||
*/
|
||||
public isCrossSigningVerified(): boolean {
|
||||
return this.crossSigningVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if we ever verified this user before (at least for
|
||||
* the history of verifications observed by this device).
|
||||
*/
|
||||
public wasCrossSigningVerified(): boolean {
|
||||
return this.crossSigningVerifiedBefore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this user's key is trusted on first use
|
||||
*/
|
||||
public isTofu(): boolean {
|
||||
return this.tofu;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the ways in which we trust a device.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
@@ -228,6 +228,8 @@ export class EncryptionSetupOperation {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
}
|
||||
// tell the backup manager to re-check the keys now that they have been (maybe) updated
|
||||
await crypto.backupManager.checkKeyBackup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export class OlmDevice {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`Storing megolm session ${senderKey}|${sessionId} with first index ` +
|
||||
session.first_known_index(),
|
||||
);
|
||||
|
||||
@@ -194,9 +194,10 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
//
|
||||
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
|
||||
// more strict and reject events that come from unknown devices.
|
||||
// If the device is unknown then we check that we don't have any pending key-query requests for the sender. If
|
||||
// after that the device is still unknown, then we can only assume that the device logged out and accept it
|
||||
// anyway. Some event handlers, such as secret sharing, may be more strict and reject events that come from
|
||||
// unknown devices.
|
||||
//
|
||||
// This is a defence against the following scenario:
|
||||
//
|
||||
@@ -204,14 +205,26 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
|
||||
// senderkey, but claiming to be from Bob.
|
||||
// * Mallory sends more events using that session, claiming to be from Bob.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
|
||||
// events as verified even though the sender is forged.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those events as
|
||||
// verified even though the sender is forged.
|
||||
//
|
||||
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
|
||||
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
|
||||
let senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser === undefined || senderKeyUser === null) {
|
||||
// Wait for any pending key query fetches for the user to complete before trying the lookup again.
|
||||
try {
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER_CHECK_FAILED", "Could not verify sender identity", {
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
}
|
||||
|
||||
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
}
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
|
||||
real_sender: senderKeyUser,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user