Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3f969698 | |||
| 96c6c99644 | |||
| 55230dd0ea | |||
| 5707b48fd2 | |||
| 8ac918c10f | |||
| 1cd8bed705 | |||
| e0dacf7529 | |||
| 29d9bdac61 | |||
| 88d066a10c | |||
| ce7b7bf44f | |||
| 07a9eb3c96 | |||
| f8f22a3edd | |||
| 084beaa947 | |||
| 73a87652fe | |||
| 4a4b454f27 | |||
| 6f82f08c7b | |||
| c41949de15 | |||
| f941fd896e | |||
| d750e33ec9 | |||
| a370442328 | |||
| bddf2b9682 | |||
| 74a2e694c3 | |||
| 748d03ba11 | |||
| 2f3f0b340e | |||
| 12e479a93e | |||
| 6e2ac03f7e | |||
| 6359e10bcf | |||
| b3a2b8b8c4 | |||
| 30a9119e31 | |||
| 7a52dba86c | |||
| d6177cdfc9 | |||
| c4f3fd3289 | |||
| 31f38550e3 | |||
| 0643f38592 | |||
| c0264954ed | |||
| 7501e28dec | |||
| febc4c9ad6 | |||
| 6b1d53cc14 | |||
| 04fcd5880b | |||
| 4bcea2cead | |||
| 6468d79458 | |||
| a871376350 | |||
| 6beb693616 | |||
| 11661bbc8d | |||
| 2d57f28d5a | |||
| c52f857599 | |||
| 5d016c1e4f | |||
| 9f04c0555c | |||
| 9293986e3b | |||
| 8426d8cae1 | |||
| 3baf6ec2c6 | |||
| 38cd6f93e6 | |||
| a3a6742c67 | |||
| 4ce837b20e | |||
| 884bd2585a | |||
| c306d87f80 | |||
| b94d137398 | |||
| 5595e8497f | |||
| 5d233f3863 | |||
| 0f4fa5ad51 | |||
| 1de6de05a1 | |||
| c8f8fb587d | |||
| 2f79e6c056 | |||
| 42be793a56 | |||
| 7c2a12085c | |||
| 3cf6f568f3 | |||
| 4db08cb78e | |||
| 25e5d79cf6 | |||
| 6c8e3d0707 | |||
| 3139f5729b | |||
| bb8a894105 | |||
| 223dfffdfb | |||
| f19f0a8793 | |||
| a5224c1820 | |||
| 513201b9c1 | |||
| 02ca5c78cf | |||
| af63d9bd05 | |||
| 95baccfbc1 | |||
| 10b6c2463d | |||
| 6e8d15e5ed | |||
| 2e4276437a | |||
| 6a761af867 | |||
| 53a72df01b | |||
| 75e710d93e | |||
| 1457ab0cf4 | |||
| 14aafb7977 | |||
| 90d00b863f | |||
| 5f0ada9578 | |||
| f01037fe0d | |||
| 2cda6655d7 | |||
| 6eec2ceeeb | |||
| 68317ac836 | |||
| 5c45c980e9 | |||
| 66251e0855 | |||
| ff53557957 | |||
| 126352afd5 | |||
| f33da83d90 | |||
| 74193ad057 | |||
| c672cad1a1 | |||
| d59bb240fa | |||
| 65d988734e | |||
| 4a402f0bd7 | |||
| a491508543 | |||
| 0abba3e626 | |||
| 9fed45e47c | |||
| fe67a68c95 | |||
| 4d3d4028a0 | |||
| 8f901590ff | |||
| d29b8520f7 | |||
| 37e1fd9af5 | |||
| 76dbc7500f | |||
| 3664f8c3c2 | |||
| d0a10497bb | |||
| 6385c9c0da | |||
| cecac3152e | |||
| f6c99b1d25 | |||
| 407ec4d67a | |||
| 4947a0cb64 | |||
| f134d6db01 | |||
| fde6cebc20 | |||
| 425cf6b91e | |||
| a3e273d6f1 | |||
| b1a3b264e5 | |||
| 053643a8ba | |||
| d2ea149012 | |||
| 23d244520c | |||
| 267b52099b | |||
| 430fd5660a | |||
| 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 | |||
| 5788d9744b | |||
| 65cbbaaf01 | |||
| c5245a887b | |||
| 321679fd63 | |||
| 15c679b29e | |||
| 85ba069117 |
@@ -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
|
||||
|
||||
@@ -15,7 +15,12 @@ concurrency:
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.77.1
|
||||
|
||||
# We only want to run the cypress tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume 4 concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.83.0-rc.1
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
@@ -29,3 +34,26 @@ jobs:
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
rust-crypto: true
|
||||
|
||||
# We want to make the cypress tests a required check for the merge queue.
|
||||
#
|
||||
# Unfortunately, github doesn't distinguish between "checks needed for branch
|
||||
# protection" (ie, the things that must pass before the PR will even be added
|
||||
# to the merge queue) and "checks needed in the merge queue". We just have to add
|
||||
# the check to the branch protection list.
|
||||
#
|
||||
# Ergo, if we know we're not going to run the cypress tests, we need to add a
|
||||
# passing status check manually.
|
||||
mark_skipped:
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
permissions:
|
||||
statuses: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Sibz/github-status-action@650dd1a882a76dbbbc4576fb5974b8d22f29847f # v1.1.6
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: success
|
||||
description: Cypress skipped
|
||||
context: "${{ github.workflow }} / cypress"
|
||||
sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
name: Build downstream artifacts
|
||||
on:
|
||||
# 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]
|
||||
|
||||
pull_request: {}
|
||||
|
||||
# For now at least, we don't run this or the cypress-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
@@ -21,7 +20,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.77.1
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.82.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Check membership
|
||||
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
|
||||
uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v3
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
|
||||
@@ -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@5a85faf05d2ade2d5b6682bfe5359915d5159c6c # v2.2.1
|
||||
uses: JS-DevTools/npm-publish@fe72237be0920f7a0cafd6a966c9b929c9466e9b # v2.2.2
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.5
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.6
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -12,18 +12,19 @@ env:
|
||||
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
|
||||
jobs:
|
||||
jest:
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node == '*' && 'latest' || matrix.node }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [18, latest]
|
||||
specs: [integ, unit]
|
||||
node: [18, "*"]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
@@ -32,13 +33,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
|
||||
- name: Build
|
||||
if: matrix.specs == 'browserify'
|
||||
run: "yarn build"
|
||||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
|
||||
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -55,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Upgrade Dependencies
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
jobs:
|
||||
upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Upgrade
|
||||
run: yarn upgrade && yarn install
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
delete-branch: true
|
||||
title: Upgrade dependencies
|
||||
labels: |
|
||||
Dependencies
|
||||
T-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.(ts|tsx)": ["eslint --fix", "prettier --write"],
|
||||
"*.(py|md|yaml)": ["prettier --write"]
|
||||
}
|
||||
@@ -1,3 +1,96 @@
|
||||
Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.0) (2023-11-07)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
|
||||
|
||||
## ✨ Features
|
||||
* Element-R: Add the git sha of the binding crate to `CryptoApi#getVersion` ([\#3838](https://github.com/matrix-org/matrix-js-sdk/pull/3838)). Contributed by @florianduros.
|
||||
* Element-R: Wire up `globalBlacklistUnverifiedDevices` field to rust crypto encryption settings ([\#3790](https://github.com/matrix-org/matrix-js-sdk/pull/3790)). Fixes vector-im/element-web#26315. Contributed by @florianduros.
|
||||
* Element-R: Wire up room rotation ([\#3807](https://github.com/matrix-org/matrix-js-sdk/pull/3807)). Fixes vector-im/element-web#26318. Contributed by @florianduros.
|
||||
* Element-R: Add current version of the rust-sdk and vodozemac ([\#3825](https://github.com/matrix-org/matrix-js-sdk/pull/3825)). Contributed by @florianduros.
|
||||
* Element-R: Wire up room history visibility ([\#3805](https://github.com/matrix-org/matrix-js-sdk/pull/3805)). Fixes vector-im/element-web#26319. Contributed by @florianduros.
|
||||
* Element-R: log when we send to-device messages ([\#3810](https://github.com/matrix-org/matrix-js-sdk/pull/3810)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix reemitter not being correctly wired on user objects created in storage classes ([\#3796](https://github.com/matrix-org/matrix-js-sdk/pull/3796)). Contributed by @MidhunSureshR.
|
||||
* Element-R: silence log errors when viewing a pending event ([\#3824](https://github.com/matrix-org/matrix-js-sdk/pull/3824)).
|
||||
* Don't emit a closed event if the indexeddb is closed by Element ([\#3832](https://github.com/matrix-org/matrix-js-sdk/pull/3832)). Fixes vector-im/element-web#25941. Contributed by @dhenneke.
|
||||
* Element-R: silence log errors when viewing a decryption failure ([\#3821](https://github.com/matrix-org/matrix-js-sdk/pull/3821)).
|
||||
|
||||
Changes in [29.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.1.0) (2023-10-24)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: refresh tokens ([\#3764](https://github.com/matrix-org/matrix-js-sdk/pull/3764)). Contributed by @kerryarchibald.
|
||||
* OIDC: add `prompt` param to auth url creation ([\#3794](https://github.com/matrix-org/matrix-js-sdk/pull/3794)). Contributed by @kerryarchibald.
|
||||
* Allow applications to specify their own logger instance ([\#3792](https://github.com/matrix-org/matrix-js-sdk/pull/3792)). Fixes #1899.
|
||||
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix sending call member events on leave ([\#3799](https://github.com/matrix-org/matrix-js-sdk/pull/3799)). Fixes vector-im/element-call#1763.
|
||||
* Don't use event.sender in CallMembership ([\#3793](https://github.com/matrix-org/matrix-js-sdk/pull/3793)).
|
||||
* Element-R: Don't mark QR code verification as done until it's done ([\#3791](https://github.com/matrix-org/matrix-js-sdk/pull/3791)). Fixes vector-im/element-web#26293.
|
||||
* Element-R: Connect device to key backup when crypto is created ([\#3784](https://github.com/matrix-org/matrix-js-sdk/pull/3784)). Fixes vector-im/element-web#26316. Contributed by @florianduros.
|
||||
* Element-R: Avoid errors in `VerificationRequest.generateQRCode` when QR code is unavailable ([\#3779](https://github.com/matrix-org/matrix-js-sdk/pull/3779)). Fixes vector-im/element-web#26300. Contributed by @florianduros.
|
||||
* ElementR: Check key backup when user identity changes ([\#3760](https://github.com/matrix-org/matrix-js-sdk/pull/3760)). Fixes vector-im/element-web#26244. Contributed by @florianduros.
|
||||
* Element-R: emit `VerificationRequestReceived` on incoming request ([\#3762](https://github.com/matrix-org/matrix-js-sdk/pull/3762)). Fixes vector-im/element-web#26245.
|
||||
|
||||
Changes in [29.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0) (2023-10-10)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Remove browserify builds ([\#3759](https://github.com/matrix-org/matrix-js-sdk/pull/3759)).
|
||||
|
||||
## ✨ Features
|
||||
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
|
||||
* Support for stable MSC3882 get_login_token ([\#3416](https://github.com/matrix-org/matrix-js-sdk/pull/3416)). Contributed by @hughns.
|
||||
* Remove IsUserMention and IsRoomMention from DEFAULT_OVERRIDE_RULES ([\#3752](https://github.com/matrix-org/matrix-js-sdk/pull/3752)). Contributed by @kerryarchibald.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix a case where joinRoom creates a duplicate Room object ([\#3747](https://github.com/matrix-org/matrix-js-sdk/pull/3747)).
|
||||
* Add membershipID to call memberships ([\#3745](https://github.com/matrix-org/matrix-js-sdk/pull/3745)).
|
||||
* Fix the warning for messages from unsigned devices ([\#3743](https://github.com/matrix-org/matrix-js-sdk/pull/3743)).
|
||||
* Stop keep alive, when sync was stoped ([\#3720](https://github.com/matrix-org/matrix-js-sdk/pull/3720)). Contributed by @finsterwalder.
|
||||
|
||||
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)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -23,19 +23,7 @@ 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`
|
||||
attached to `window` through which you can access the SDK. See below for how to
|
||||
include libolm to enable end-to-end-encryption.
|
||||
|
||||
The browser bundle supports recent versions of browsers. Typically this is ES2015
|
||||
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
|
||||
[browserlists](https://github.com/browserslist/browserslist).
|
||||
|
||||
Please check [the working browser example](examples/browser) for more information.
|
||||
### Note, the browserify build has been removed. Please use a bundler like webpack or vite instead.
|
||||
|
||||
## In Node.js
|
||||
|
||||
@@ -363,7 +351,7 @@ First, you need to pull in the right build tools:
|
||||
|
||||
## Building
|
||||
|
||||
To build a browser version from scratch when developing::
|
||||
To build a browser version from scratch when developing:
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
@@ -375,9 +363,6 @@ To run tests (Jest):
|
||||
$ yarn test
|
||||
```
|
||||
|
||||
> **Note**
|
||||
> The `sync-browserify.spec.ts` requires a browser build (`yarn build`) in order to pass
|
||||
|
||||
To run linting:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Summary
|
||||
|
||||
- [Introduction](../README.md)
|
||||
|
||||
# Deep dive
|
||||
|
||||
- [Storage notes](storage-notes.md)
|
||||
- [Unverified devices](warning-on-unverified-devices.md)
|
||||
@@ -1,31 +1,29 @@
|
||||
Random notes from Matthew on the two possible approaches for warning users about unexpected
|
||||
unverified devices popping up in their rooms....
|
||||
|
||||
Original idea...
|
||||
================
|
||||
# Original idea...
|
||||
|
||||
Warn when an existing user adds an unknown device to a room.
|
||||
|
||||
Warn when a user joins the room with unverified or unknown devices.
|
||||
|
||||
Warn when you initial sync if the room has any unverified devices in it.
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
Warn when you initial sync if the room has any new undefined devices since you were last there.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
|
||||
|
||||
Updated idea...
|
||||
===============
|
||||
# Updated idea...
|
||||
|
||||
Warn when the user tries to send a message:
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
...this should be good enough.
|
||||
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
throw an error when trying to encrypt if there are pure unverified devices there
|
||||
app will have to search for the devices which are pure unverified to warn about them - have to do this from MembersList anyway?
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
@@ -1,10 +0,0 @@
|
||||
To try it out, **you must build the SDK first** and then host this folder:
|
||||
|
||||
```
|
||||
$ yarn install
|
||||
$ yarn build
|
||||
$ cd examples/browser
|
||||
$ python -m http.server 8003
|
||||
```
|
||||
|
||||
Then visit `http://localhost:8003`.
|
||||
@@ -1,9 +0,0 @@
|
||||
console.log("Loading browser sdk");
|
||||
|
||||
var client = matrixcs.createClient({ baseUrl: "https://matrix.org" });
|
||||
client.publicRooms().then(function (data) {
|
||||
console.log("data %s [...]", JSON.stringify(data).substring(0, 100));
|
||||
console.log("Congratulations! The SDK is working on the browser!");
|
||||
var result = document.getElementById("result");
|
||||
result.innerHTML = "<p>The SDK appears to be working correctly.</p>";
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
<script src="lib/matrix.js"></script>
|
||||
<script src="browserTest.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
Sanity Testing (check the console) : This example is here to make sure that the SDK works inside a browser. It
|
||||
simply does a GET /publicRooms on matrix.org
|
||||
<br />
|
||||
You should see a message confirming that the SDK works below:
|
||||
<br />
|
||||
<div id="result"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
../../../dist/browser-matrix.js
|
||||
@@ -1,2 +0,0 @@
|
||||
olm.js
|
||||
olm.wasm
|
||||
@@ -1 +0,0 @@
|
||||
../../../dist/browser-matrix.js
|
||||
@@ -1,60 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Test Crypto in Browser</title>
|
||||
<script src="lib/olm.js"></script>
|
||||
<script src="lib/matrix.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing export/import of Olm devices in the browser</h1>
|
||||
<ul>
|
||||
<li>Make sure you built the current version of the Matrix JS SDK (<code>yarn build</code>)</li>
|
||||
<li>
|
||||
copy <code>olm.js</code> and <code>olm.wasm</code> from a recent release of Olm (was tested with version
|
||||
3.1.4) in directory <code>lib/</code>
|
||||
</li>
|
||||
<li>start a local Matrix homeserver (on port 8008, or change the port in the code)</li>
|
||||
<li>Serve this HTML file (e.g. <code>python3 -m http.server</code>) and go to it through your browser</li>
|
||||
<li>
|
||||
in the JS console, do:
|
||||
<pre>
|
||||
aliceMatrixClient = await newMatrixClient("alice-"+randomHex());
|
||||
await aliceMatrixClient.exportDevice();
|
||||
await aliceMatrixClient.getAccessToken();
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
copy the result of <code>exportDevice</code> and <code>getAccessToken</code> somewhere (<strong
|
||||
>not</strong
|
||||
>
|
||||
in a JS variable as it will be destroyed when you refresh the page)
|
||||
</li>
|
||||
<li><strong>refresh the page (F5)</strong> to make sure the client is destroyed</li>
|
||||
<li>
|
||||
Do the following, replacing <code>ALICE_ID</code>
|
||||
with the user ID of Alice (you can find it in the exported data)
|
||||
<pre>
|
||||
bobMatrixClient = await newMatrixClient("bob-"+randomHex());
|
||||
roomId = await bobMatrixClient.createEncryptedRoom([ALICE_ID]);
|
||||
await bobMatrixClient.sendTextMessage('Hi Alice!', roomId);
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>Again, <strong>refresh the page (F5)</strong>. You may want to clear your console as well.</li>
|
||||
<li>
|
||||
Now do the following, using the exported data and the access token you saved previously:
|
||||
<pre>
|
||||
aliceMatrixClient = await importMatrixClient(EXPORTED_DATA, ACCESS_TOKEN);
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>You should see the message sent by Bob printed in the console.</li>
|
||||
</ul>
|
||||
|
||||
<script src="olm-device-export-import.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +0,0 @@
|
||||
if (!Olm) {
|
||||
console.error("global.Olm does not seem to be present." + " Did you forget to add olm in the lib/ directory?");
|
||||
}
|
||||
|
||||
const BASE_URL = "http://localhost:8008";
|
||||
const ROOM_CRYPTO_CONFIG = { algorithm: "m.megolm.v1.aes-sha2" };
|
||||
const PASSWORD = "password";
|
||||
|
||||
// useful to create new usernames
|
||||
window.randomHex = () => Math.floor(Math.random() * 10 ** 6).toString(16);
|
||||
|
||||
window.newMatrixClient = async function (username) {
|
||||
const registrationClient = matrixcs.createClient(BASE_URL);
|
||||
|
||||
const userRegisterResult = await registrationClient.register(username, PASSWORD, null, { type: "m.login.dummy" });
|
||||
|
||||
const matrixClient = matrixcs.createClient({
|
||||
baseUrl: BASE_URL,
|
||||
userId: userRegisterResult.user_id,
|
||||
accessToken: userRegisterResult.access_token,
|
||||
deviceId: userRegisterResult.device_id,
|
||||
sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage),
|
||||
cryptoStore: new matrixcs.MemoryCryptoStore(),
|
||||
});
|
||||
|
||||
extendMatrixClient(matrixClient);
|
||||
|
||||
await matrixClient.initCrypto();
|
||||
await matrixClient.startClient();
|
||||
return matrixClient;
|
||||
};
|
||||
|
||||
window.importMatrixClient = async function (exportedDevice, accessToken) {
|
||||
const matrixClient = matrixcs.createClient({
|
||||
baseUrl: BASE_URL,
|
||||
deviceToImport: exportedDevice,
|
||||
accessToken,
|
||||
sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage),
|
||||
cryptoStore: new matrixcs.MemoryCryptoStore(),
|
||||
});
|
||||
|
||||
extendMatrixClient(matrixClient);
|
||||
|
||||
await matrixClient.initCrypto();
|
||||
await matrixClient.startClient();
|
||||
return matrixClient;
|
||||
};
|
||||
|
||||
function extendMatrixClient(matrixClient) {
|
||||
// automatic join
|
||||
matrixClient.on("RoomMember.membership", async (event, member) => {
|
||||
if (member.membership === "invite" && member.userId === matrixClient.getUserId()) {
|
||||
await matrixClient.joinRoom(member.roomId);
|
||||
// setting up of room encryption seems to be triggered automatically
|
||||
// but if we don't wait for it the first messages we send are unencrypted
|
||||
await matrixClient.setRoomEncryption(member.roomId, { algorithm: "m.megolm.v1.aes-sha2" });
|
||||
}
|
||||
});
|
||||
|
||||
matrixClient.onDecryptedMessage = (message) => {
|
||||
console.log("Got encrypted message: ", message);
|
||||
};
|
||||
|
||||
matrixClient.on("Event.decrypted", (event) => {
|
||||
if (event.getType() === "m.room.message") {
|
||||
matrixClient.onDecryptedMessage(event.getContent().body);
|
||||
} else {
|
||||
console.log("decrypted an event of type", event.getType());
|
||||
console.log(event);
|
||||
}
|
||||
});
|
||||
|
||||
matrixClient.createEncryptedRoom = async function (usersToInvite) {
|
||||
const { room_id: roomId } = await this.createRoom({
|
||||
visibility: "private",
|
||||
invite: usersToInvite,
|
||||
});
|
||||
|
||||
// matrixClient.setRoomEncryption() only updates local state
|
||||
// but does not send anything to the server
|
||||
// (see https://github.com/matrix-org/matrix-js-sdk/issues/905)
|
||||
// so we do it ourselves with 'sendStateEvent'
|
||||
await this.sendStateEvent(roomId, "m.room.encryption", ROOM_CRYPTO_CONFIG);
|
||||
await this.setRoomEncryption(roomId, ROOM_CRYPTO_CONFIG);
|
||||
|
||||
// Marking all devices as verified
|
||||
let room = this.getRoom(roomId);
|
||||
let members = (await room.getEncryptionTargetMembers()).map((x) => x["userId"]);
|
||||
let memberkeys = await this.downloadKeys(members);
|
||||
for (const userId in memberkeys) {
|
||||
for (const deviceId in memberkeys[userId]) {
|
||||
await this.setDeviceVerified(userId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return roomId;
|
||||
};
|
||||
|
||||
matrixClient.sendTextMessage = async function (message, roomId) {
|
||||
return matrixClient.sendMessage(roomId, {
|
||||
body: message,
|
||||
msgtype: "m.text",
|
||||
});
|
||||
};
|
||||
}
|
||||
+11
-35
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "28.0.0",
|
||||
"version": "30.0.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -8,14 +8,11 @@
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"dist": "echo 'This is for the release script so it can make assets (browser bundle).' && yarn build",
|
||||
"clean": "rimraf lib dist",
|
||||
"build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser",
|
||||
"clean": "rimraf lib",
|
||||
"build": "yarn build:dev",
|
||||
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"build:compile-browser": "mkdir dist && BROWSERIFYSWAP_ENV='no-rust-crypto' browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
|
||||
@@ -42,7 +39,6 @@
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib",
|
||||
"src",
|
||||
"git-revision.txt",
|
||||
@@ -55,14 +51,14 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.1",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^2.2.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.5.0",
|
||||
"matrix-widget-api": "^1.6.0",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
@@ -82,8 +78,8 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "^2.2.5",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
@@ -97,11 +93,9 @@
|
||||
"allchange": "^1.0.6",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"browserify": "^17.0.0",
|
||||
"browserify-swap": "^0.2.2",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.46.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
@@ -112,12 +106,14 @@
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "^8.0.3",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"rimraf": "^5.0.0",
|
||||
@@ -137,25 +133,5 @@
|
||||
"outputName": "jest-sonar-report.xml",
|
||||
"relativePaths": true
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
"browserify-swap",
|
||||
[
|
||||
"babelify",
|
||||
{
|
||||
"sourceMaps": "inline",
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-typescript"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"browserify-swap": {
|
||||
"no-rust-crypto": {
|
||||
"src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts"
|
||||
}
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 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 "../../dist/browser-matrix"; // uses browser-matrix instead of the src
|
||||
import type { default as BrowserMatrix } from "../../src/browser-index";
|
||||
|
||||
// stub for browser-matrix browserify tests
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = jest.fn();
|
||||
|
||||
afterAll(() => {
|
||||
// clean up XMLHttpRequest mock
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = undefined;
|
||||
});
|
||||
|
||||
// Akin to spec/setupTests.ts - but that won't affect the browser-matrix bundle
|
||||
global.matrixcs = {
|
||||
...global.matrixcs,
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
} as typeof BrowserMatrix;
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 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 HttpBackend from "matrix-mock-request";
|
||||
|
||||
import "./setupTests"; // uses browser-matrix instead of the src
|
||||
import type { MatrixClient } from "../../src";
|
||||
|
||||
const USER_ID = "@user:test.server";
|
||||
const DEVICE_ID = "device_id";
|
||||
const ACCESS_TOKEN = "access_token";
|
||||
const ROOM_ID = "!room_id:server.test";
|
||||
|
||||
describe("Browserify Test", function () {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new HttpBackend();
|
||||
client = new global.matrixcs.MatrixClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: USER_ID,
|
||||
accessToken: ACCESS_TOKEN,
|
||||
deviceId: DEVICE_ID,
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
});
|
||||
|
||||
httpBackend.when("GET", "/versions").respond(200, {});
|
||||
httpBackend.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
client.stopClient();
|
||||
client.http.abort();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
await httpBackend.stop();
|
||||
});
|
||||
|
||||
it("Sync", async () => {
|
||||
const event = {
|
||||
type: "m.room.member",
|
||||
room_id: ROOM_ID,
|
||||
content: {
|
||||
membership: "join",
|
||||
name: "Displayname",
|
||||
},
|
||||
event_id: "$foobar",
|
||||
};
|
||||
|
||||
const syncData = {
|
||||
next_batch: "batch1",
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [event],
|
||||
limited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
const syncPromise = new Promise((r) => client.once(global.matrixcs.ClientEvent.Sync, r));
|
||||
const unexpectedErrorFn = jest.fn();
|
||||
client.once(global.matrixcs.ClientEvent.SyncUnexpectedError, unexpectedErrorFn);
|
||||
|
||||
client.startClient();
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await syncPromise;
|
||||
expect(unexpectedErrorFn).not.toHaveBeenCalled();
|
||||
}, 20000); // additional timeout as this test can take quite a while
|
||||
});
|
||||
+892
-520
File diff suppressed because it is too large
Load Diff
@@ -18,62 +18,28 @@ import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
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 {
|
||||
advanceTimersUntil,
|
||||
awaitDecryption,
|
||||
CRYPTO_BACKENDS,
|
||||
InitCrypto,
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
|
||||
const ROOM_ID = "!ROOM:ID";
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
|
||||
const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
|
||||
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
|
||||
|
||||
const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
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 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",
|
||||
},
|
||||
};
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
@@ -139,7 +105,8 @@ function mockUploadEmitter(
|
||||
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 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 */
|
||||
@@ -150,9 +117,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
@@ -174,6 +141,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
@@ -189,44 +157,252 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
return client;
|
||||
}
|
||||
|
||||
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
describe("Key backup check on UTD message", () => {
|
||||
// sync response which contains an encrypted event
|
||||
const SYNC_RESPONSE = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
||||
};
|
||||
|
||||
const EXPECTED_URL =
|
||||
[
|
||||
"https://alice-server.com/_matrix/client/v3/room_keys/keys",
|
||||
encodeURIComponent(testData.TEST_ROOM_ID),
|
||||
encodeURIComponent(testData.MEGOLM_SESSION_DATA.session_id),
|
||||
].join("/") + "?version=1";
|
||||
|
||||
/** Flush promises enough times to get the crypto stacks to make the backup request */
|
||||
async function flushBackupRequest() {
|
||||
// we have to run flushPromises lots of times. It seems like each time the rust code touches indexeddb,
|
||||
// it needs another round of flushPromises to progress, or something.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// 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 aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == "1") {
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "1",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
it("handles error on backup query gracefully", async () => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
await flushBackupRequest();
|
||||
|
||||
// we should not have logged an error.
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Only queries once", async () => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// another message
|
||||
const event2 = { ...testData.ENCRYPTED_EVENT, event_id: "$event2" };
|
||||
const syncResponse2 = {
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [event2] } } } },
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse2);
|
||||
await flushBackupRequest();
|
||||
expect(fetchMock.calls("getKey").length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
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]: {
|
||||
timeline: {
|
||||
events: [ENCRYPTED_EVENT],
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
let onKeyCached: () => void;
|
||||
const awaitKeyCached = new Promise<void>((resolve) => {
|
||||
onKeyCached = resolve;
|
||||
});
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.checkKeyBackup();
|
||||
const result = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 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);
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent()).toEqual("testytest");
|
||||
await awaitKeyCached;
|
||||
|
||||
// The key should be now cached
|
||||
const afterCache = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!),
|
||||
);
|
||||
|
||||
expect(afterCache.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
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 advanceTimersUntil(
|
||||
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", () => {
|
||||
@@ -566,6 +742,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
@@ -579,6 +756,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright 2016-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 Olm from "@matrix-org/olm";
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import { IContent, IDeviceKeys, IDownloadKeyResult, IEvent, Keys, MatrixClient, SigningKeys } from "../../../src";
|
||||
import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
|
||||
/**
|
||||
* @module
|
||||
*
|
||||
* A set of utilities for creating Olm accounts and sessions, and encrypting/decrypting with Olm/Megolm.
|
||||
*/
|
||||
|
||||
/** Create an Olm Account object */
|
||||
export async function createOlmAccount(): Promise<Olm.Account> {
|
||||
await Olm.init();
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
return testOlmAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device keys for the test Olm Account
|
||||
*
|
||||
* @param olmAccount - Test olm account
|
||||
* @param userId - The user ID to present the keys as belonging to
|
||||
*/
|
||||
export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, deviceId: string): IDeviceKeys {
|
||||
const testE2eKeys = JSON.parse(olmAccount.identity_keys());
|
||||
const testDeviceKeys: IDeviceKeys = {
|
||||
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
device_id: deviceId,
|
||||
keys: {
|
||||
[`curve25519:${deviceId}`]: testE2eKeys.curve25519,
|
||||
[`ed25519:${deviceId}`]: testE2eKeys.ed25519,
|
||||
},
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
const j = anotherjson.stringify(testDeviceKeys);
|
||||
const sig = olmAccount.sign(j);
|
||||
testDeviceKeys.signatures = { [userId]: { [`ed25519:${deviceId}`]: sig } };
|
||||
return testDeviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap cross signing for the given Olm account.
|
||||
*
|
||||
* Will generate the cross signing keys and sign them with the master key, and returns the `IDownloadKeyResult`
|
||||
* that can be directly fed into a test e2eKeyResponder.
|
||||
*
|
||||
* The cross-signing keys are randomly generated, similar to how the olm account keys are generated. There may not
|
||||
* be any value in using static vectors, as the device keys change at every test run.
|
||||
*
|
||||
* If some `KeyBackupInfo` are provided, the `auth_data` of each backup info will be signed with the
|
||||
* master key, meaning the backups will be then trusted after verification.
|
||||
*
|
||||
* @param olmAccount - The Olm account object to use for signing the device keys.
|
||||
* @param userId - The user ID to associate with the device keys.
|
||||
* @param deviceId - The device ID to associate with the device keys.
|
||||
* @param keyBackupInfo - Optional key backup infos to sign with the master key.
|
||||
* @returns A valid keys/query response that can be fed into a test e2eKeyResponder.
|
||||
*/
|
||||
export function bootstrapCrossSigningTestOlmAccount(
|
||||
olmAccount: Olm.Account,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new global.Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new global.Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new global.Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
const mskInfo: Keys = {
|
||||
user_id: userId,
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
["ed25519:" + masterPubkey]: masterPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
const sskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
["ed25519:" + sskPubkey]: sskPubkey,
|
||||
},
|
||||
};
|
||||
// sign the ssk with the msk
|
||||
const sskSig = olmAliceMSK.sign(anotherjson.stringify(sskInfo));
|
||||
sskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
|
||||
const uskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["user_signing"],
|
||||
keys: {
|
||||
["ed25519:" + userPubkey]: userPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
// sign the usk with the msk
|
||||
const uskSig = olmAliceMSK.sign(anotherjson.stringify(uskInfo));
|
||||
uskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: uskSig,
|
||||
},
|
||||
};
|
||||
|
||||
// get the device keys and sign them with the ssk (the device is then cross signed)
|
||||
const deviceKeys = getTestOlmAccountKeys(olmAccount, userId, deviceId);
|
||||
|
||||
const copy = Object.assign({}, deviceKeys);
|
||||
delete copy.signatures;
|
||||
const crossSignature = olmAliceSSK.sign(anotherjson.stringify(copy));
|
||||
|
||||
// add the signature
|
||||
deviceKeys.signatures![userId]["ed25519:" + sskPubkey] = crossSignature;
|
||||
|
||||
// if we have some key backup info, sign them with the msk
|
||||
keyBackupInfo.forEach((info) => {
|
||||
const unsignedAuthData = Object.assign({}, info.auth_data);
|
||||
delete unsignedAuthData.signatures;
|
||||
const backupSignature = olmAliceMSK.sign(anotherjson.stringify(unsignedAuthData));
|
||||
|
||||
info.auth_data.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: backupSignature,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// clean the olm resources as we don't need them anymore
|
||||
olmAliceMSK.free();
|
||||
olmAliceSSK.free();
|
||||
olmAliceUSK.free();
|
||||
|
||||
return {
|
||||
master_keys: { [userId]: mskInfo },
|
||||
user_signing_keys: { [userId]: uskInfo as SigningKeys },
|
||||
self_signing_keys: { [userId]: sskInfo as SigningKeys },
|
||||
device_keys: { [userId]: { [deviceId]: deviceKeys } },
|
||||
};
|
||||
}
|
||||
|
||||
/** start an Olm session with a given recipient */
|
||||
export async function createOlmSession(
|
||||
olmAccount: Olm.Account,
|
||||
recipientTestClient: IE2EKeyReceiver,
|
||||
): Promise<Olm.Session> {
|
||||
const keys = await recipientTestClient.awaitOneTimeKeyUpload();
|
||||
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;
|
||||
}
|
||||
|
||||
// IToDeviceEvent isn't exported by src/sync-accumulator.ts
|
||||
export interface ToDeviceEvent {
|
||||
content: IContent;
|
||||
sender: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** encrypt an event with an existing olm session */
|
||||
export function encryptOlmEvent(opts: {
|
||||
/** the sender's user id */
|
||||
sender?: string;
|
||||
/** the sender's curve25519 key */
|
||||
senderKey: string;
|
||||
/** the sender's ed25519 key */
|
||||
senderSigningKey: string;
|
||||
/** the olm session to use for encryption */
|
||||
p2pSession: Olm.Session;
|
||||
/** the recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** the payload of the message */
|
||||
plaincontent?: object;
|
||||
/** the event type of the payload */
|
||||
plaintype?: string;
|
||||
}): ToDeviceEvent {
|
||||
expect(opts.senderKey).toBeTruthy();
|
||||
expect(opts.p2pSession).toBeTruthy();
|
||||
expect(opts.recipient).toBeTruthy();
|
||||
|
||||
const plaintext = {
|
||||
content: opts.plaincontent || {},
|
||||
recipient: opts.recipient,
|
||||
recipient_keys: {
|
||||
ed25519: opts.recipientEd25519Key,
|
||||
},
|
||||
keys: {
|
||||
ed25519: opts.senderSigningKey,
|
||||
},
|
||||
sender: opts.sender || "@bob:xyz",
|
||||
type: opts.plaintype || "m.test",
|
||||
};
|
||||
|
||||
return {
|
||||
content: {
|
||||
algorithm: "m.olm.v1.curve25519-aes-sha2",
|
||||
ciphertext: {
|
||||
[opts.recipientCurve25519Key]: opts.p2pSession.encrypt(JSON.stringify(plaintext)),
|
||||
},
|
||||
sender_key: opts.senderKey,
|
||||
},
|
||||
sender: opts.sender || "@bob:xyz",
|
||||
type: "m.room.encrypted",
|
||||
};
|
||||
}
|
||||
|
||||
// encrypt an event with megolm
|
||||
export function encryptMegolmEvent(opts: {
|
||||
senderKey: string;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
plaintext?: Partial<IEvent>;
|
||||
room_id?: string;
|
||||
}): IEvent {
|
||||
expect(opts.senderKey).toBeTruthy();
|
||||
expect(opts.groupSession).toBeTruthy();
|
||||
|
||||
const plaintext = opts.plaintext || {};
|
||||
if (!plaintext.content) {
|
||||
plaintext.content = {
|
||||
body: "42",
|
||||
msgtype: "m.text",
|
||||
};
|
||||
}
|
||||
if (!plaintext.type) {
|
||||
plaintext.type = "m.room.message";
|
||||
}
|
||||
if (!plaintext.room_id) {
|
||||
expect(opts.room_id).toBeTruthy();
|
||||
plaintext.room_id = opts.room_id;
|
||||
}
|
||||
return encryptMegolmEventRawPlainText({
|
||||
senderKey: opts.senderKey,
|
||||
groupSession: opts.groupSession,
|
||||
plaintext,
|
||||
});
|
||||
}
|
||||
|
||||
export function encryptMegolmEventRawPlainText(opts: {
|
||||
senderKey: string;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
plaintext: Partial<IEvent>;
|
||||
origin_server_ts?: number;
|
||||
}): IEvent {
|
||||
return {
|
||||
event_id: "$test_megolm_event_" + Math.random(),
|
||||
sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com",
|
||||
origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000,
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)),
|
||||
device_id: "testDevice",
|
||||
sender_key: opts.senderKey,
|
||||
session_id: opts.groupSession.session_id(),
|
||||
},
|
||||
type: "m.room.encrypted",
|
||||
unsigned: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an encrypted room_key event to share a group session, using an existing olm session */
|
||||
export function encryptGroupSessionKey(opts: {
|
||||
/** recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** sender's olm account */
|
||||
olmAccount: Olm.Account;
|
||||
/** sender's olm session with the recipient */
|
||||
p2pSession: Olm.Session;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
room_id?: string;
|
||||
}): ToDeviceEvent {
|
||||
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
|
||||
return encryptOlmEvent({
|
||||
senderKey: senderKeys.curve25519,
|
||||
senderSigningKey: senderKeys.ed25519,
|
||||
recipient: opts.recipient,
|
||||
recipientCurve25519Key: opts.recipientCurve25519Key,
|
||||
recipientEd25519Key: opts.recipientEd25519Key,
|
||||
p2pSession: opts.p2pSession,
|
||||
plaincontent: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: opts.room_id,
|
||||
session_id: opts.groupSession.session_id(),
|
||||
session_key: opts.groupSession.session_key(),
|
||||
},
|
||||
plaintype: "m.room_key",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test utility to correctly encrypt a secret send event to a test device using the provided p2p session.
|
||||
*
|
||||
* @param opts - the options for the secret send event
|
||||
* @returns the to-device event, ready to be returned in a sync response for the test device.
|
||||
*/
|
||||
export function encryptSecretSend(opts: {
|
||||
/** the sender's user id */
|
||||
sender: string;
|
||||
/** recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** sender's olm account */
|
||||
olmAccount: Olm.Account;
|
||||
/** sender's olm session with the recipient */
|
||||
p2pSession: Olm.Session;
|
||||
/** The requestId of the secret request that this secret send is replying. */
|
||||
requestId: string;
|
||||
/** The secret value */
|
||||
secret: string;
|
||||
}): ToDeviceEvent {
|
||||
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
|
||||
return encryptOlmEvent({
|
||||
sender: opts.sender,
|
||||
senderKey: senderKeys.curve25519,
|
||||
senderSigningKey: senderKeys.ed25519,
|
||||
recipient: opts.recipient,
|
||||
recipientCurve25519Key: opts.recipientCurve25519Key,
|
||||
recipientEd25519Key: opts.recipientEd25519Key,
|
||||
p2pSession: opts.p2pSession,
|
||||
plaincontent: {
|
||||
request_id: opts.requestId,
|
||||
secret: opts.secret,
|
||||
},
|
||||
plaintype: "m.secret.send",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an Olm Session with the test user
|
||||
*
|
||||
* Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will
|
||||
* establish an Olm session.
|
||||
*
|
||||
* @param testClient - the MatrixClient under test, which we expect to upload account keys, and to make a
|
||||
* /sync request which we will respond to.
|
||||
* @param keyReceiver - an IE2EKeyReceiver which will intercept the /keys/upload request from the client under test
|
||||
* @param syncResponder - an ISyncResponder which will intercept /sync requests from the client under test
|
||||
* @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session.
|
||||
*/
|
||||
export async function establishOlmSession(
|
||||
testClient: MatrixClient,
|
||||
keyReceiver: IE2EKeyReceiver,
|
||||
syncResponder: ISyncResponder,
|
||||
peerOlmAccount: Olm.Account,
|
||||
): Promise<Olm.Session> {
|
||||
const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys());
|
||||
const p2pSession = await createOlmSession(peerOlmAccount, keyReceiver);
|
||||
const olmEvent = encryptOlmEvent({
|
||||
senderKey: peerE2EKeys.curve25519,
|
||||
senderSigningKey: peerE2EKeys.ed25519,
|
||||
recipient: testClient.getUserId()!,
|
||||
recipientCurve25519Key: keyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: keyReceiver.getSigningKey(),
|
||||
p2pSession: p2pSession,
|
||||
});
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
to_device: { events: [olmEvent] },
|
||||
});
|
||||
await syncPromise(testClient);
|
||||
return p2pSession;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
await expect(() => unknownDeviceClient.initRustCrypto()).rejects.toThrow("unknown deviceId");
|
||||
});
|
||||
|
||||
it("should create the indexed dbs", async () => {
|
||||
it("should create the indexed db", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
@@ -53,7 +53,25 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// should have two dbs now
|
||||
// should have an indexed db now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the meta db if given a pickleKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
pickleKey: "testKey",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// should have two indexed dbs now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(
|
||||
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
|
||||
@@ -78,6 +96,7 @@ describe("MatrixClient.clearStores", () => {
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
pickleKey: "testKey",
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
@@ -87,4 +106,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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,15 @@ import { MockResponse } from "fetch-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import {
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
DeviceVerification,
|
||||
IContent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
@@ -41,13 +44,23 @@ import {
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { CRYPTO_BACKENDS, emitPromise, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import {
|
||||
awaitDecryption,
|
||||
CRYPTO_BACKENDS,
|
||||
emitPromise,
|
||||
getSyncResponse,
|
||||
InitCrypto,
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
BOB_SIGNED_TEST_DEVICE_DATA,
|
||||
BOB_TEST_USER_ID,
|
||||
CURVE25519_KEY_BACKUP_DATA,
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
@@ -55,11 +68,20 @@ import {
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
TEST_ROOM_ID,
|
||||
TEST_USER_ID,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
} from "../../test-utils/test-data";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import {
|
||||
bootstrapCrossSigningTestOlmAccount,
|
||||
createOlmSession,
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
encryptSecretSend,
|
||||
ToDeviceEvent,
|
||||
} from "./olm-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { encodeBase64 } from "../../../src/base64";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
@@ -138,6 +160,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
beforeEach(async () => {
|
||||
// pretend that we have another device, which we will verify
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp("m.secret.request")}`),
|
||||
{ ok: false, status: 404 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
// test with (1) the default verification method list, (2) a custom verification method list.
|
||||
@@ -409,6 +437,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
|
||||
it("can verify another via QR code with an untrusted cross-signing key", async () => {
|
||||
// This is a slightly weird thing to test; if we don't trust the cross-signing key, normally we would
|
||||
// spam out a verification request to all devices rather than targeting a single device. Still, it's
|
||||
// a thing both the Matrix protocol and the js-sdk API support, so we may as well test it.
|
||||
//
|
||||
// Since we don't yet trust the master key, this is a type 0x02 QR code:
|
||||
// "self-verifying in which the current device does not yet trust the master key"
|
||||
//
|
||||
// By the end of it, we should trust the master key.
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
@@ -480,12 +517,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
reciprocateQRCodeCallbacks.confirm();
|
||||
await sendToDevicePromise;
|
||||
|
||||
// at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done".
|
||||
// Rust crypto, on the other hand, waits for the 'done' to arrive from the other side.
|
||||
if (request.phase === VerificationPhase.Done) {
|
||||
// legacy crypto: we're all done
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
await verificationPromise;
|
||||
} else {
|
||||
// rust crypto: still in flight
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
}
|
||||
|
||||
// the dummy device replies with its own 'done'
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
// ... and now we're really done.
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can try to generate a QR code when QR code is not supported", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// we need cross-signing keys for a QR code verification
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can only use SaS
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// Alice tries to generate a QR Code but it's unavailable
|
||||
const qrCodeBuffer = await request.generateQRCode();
|
||||
expect(qrCodeBuffer).toBeUndefined();
|
||||
});
|
||||
|
||||
newBackendOnly("can verify another by scanning their QR code", async () => {
|
||||
@@ -897,6 +973,491 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
});
|
||||
|
||||
describe("Incoming verification in a DM", () => {
|
||||
let testOlmAccount: Olm.Account;
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
aliceClient.setGlobalErrorOnUnknownDevices(false);
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID]));
|
||||
await syncPromise(aliceClient);
|
||||
});
|
||||
|
||||
/**
|
||||
* Return a plaintext verification request event from Bob to Alice
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest
|
||||
*/
|
||||
function createVerificationRequestEvent(): IEvent {
|
||||
return {
|
||||
content: {
|
||||
body: "Verification request from Bob to Alice",
|
||||
from_device: "BobDevice",
|
||||
methods: ["m.sas.v1"],
|
||||
msgtype: "m.key.verification.request",
|
||||
to: aliceClient.getUserId()!,
|
||||
},
|
||||
event_id: "$143273582443PhrSn:example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
room_id: TEST_ROOM_ID,
|
||||
sender: "@bob:xyz",
|
||||
type: "m.room.message",
|
||||
unsigned: {
|
||||
age: 1234,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a to-device event from Bob to Alice, sharing the group session key
|
||||
* @param groupSession - group session key to share
|
||||
* @param p2pSession - test Olm session to encrypt the key with
|
||||
*/
|
||||
function encryptGroupSessionKeyForAlice(
|
||||
groupSession: Olm.OutboundGroupSession,
|
||||
p2pSession: Olm.Session,
|
||||
): ToDeviceEvent {
|
||||
return encryptGroupSessionKey({
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
olmAccount: testOlmAccount,
|
||||
p2pSession: p2pSession,
|
||||
groupSession: groupSession,
|
||||
room_id: TEST_ROOM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and encrypt a verification request event
|
||||
* @param groupSession
|
||||
*/
|
||||
function createEncryptedVerificationRequest(groupSession: Olm.OutboundGroupSession): IEvent {
|
||||
const testOlmAccountKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
return encryptMegolmEvent({
|
||||
senderKey: testOlmAccountKeys.curve25519,
|
||||
groupSession: groupSession,
|
||||
room_id: TEST_ROOM_ID,
|
||||
plaintext: createVerificationRequestEvent(),
|
||||
});
|
||||
}
|
||||
|
||||
it("Verification request not found", async () => {
|
||||
// Expect to not find any verification request
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
expect(request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores old verification requests", async () => {
|
||||
const eventHandler = jest.fn();
|
||||
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
|
||||
|
||||
const verificationRequestEvent = createVerificationRequestEvent();
|
||||
verificationRequestEvent.origin_server_ts -= 1000000;
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, verificationRequestEvent);
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// make sure the event has arrived
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
|
||||
|
||||
// check that an event has not been raised, and that the request is not found
|
||||
expect(eventHandler).not.toHaveBeenCalled();
|
||||
expect(
|
||||
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("Plaintext verification request from Bob to Alice", async () => {
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
|
||||
|
||||
// Wait for the request to be received
|
||||
const request1 = await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
it("Encrypted verification request from Bob to Alice", async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
const requestEventPromise = emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
|
||||
// Send Bob the room keys
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Wait for the request to be decrypted
|
||||
const request1 = await requestEventPromise;
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"If the verification request is not decrypted within 5 minutes, the request is ignored",
|
||||
async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
// Advance time by 5mins, the verification request should be ignored after that
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
|
||||
// Send Bob the room keys
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
// Wait for the message to be decrypted
|
||||
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// the request should not be present
|
||||
expect(request).not.toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Secrets are gossiped after verification", () => {
|
||||
// We use a legacy olm session as the existing session.
|
||||
// This will give us access to low level olm functions in order to
|
||||
// simulate a backup key request with proper olm encryption.
|
||||
let testOlmAccount: Olm.Account;
|
||||
const olmDeviceId = "OLM_DEVICE";
|
||||
let usermasterPubKey: string;
|
||||
|
||||
const matchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
|
||||
const nonMatchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
const unknownAlgorithmBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.foo_bar",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const bootstrapped = bootstrapCrossSigningTestOlmAccount(testOlmAccount, TEST_USER_ID, olmDeviceId, [
|
||||
matchingBackupInfo,
|
||||
nonMatchingBackupInfo,
|
||||
]);
|
||||
|
||||
e2eKeyResponder.addDeviceKeys(bootstrapped.device_keys![TEST_USER_ID]![olmDeviceId]);
|
||||
e2eKeyResponder.addCrossSigningData(bootstrapped);
|
||||
|
||||
usermasterPubKey = Object.values(bootstrapped.master_keys![TEST_USER_ID].keys)[0];
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([TEST_USER_ID]));
|
||||
await syncPromise(aliceClient);
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The client should now know about the olm device
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(olmDeviceId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
aliceClient?.stopClient();
|
||||
testOlmAccount?.free();
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
newBackendOnly("Should request cross signing keys after verification", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
// The secret must have been requested
|
||||
await requestPromises.get("m.cross_signing.master");
|
||||
await requestPromises.get("m.cross_signing.user_signing");
|
||||
await requestPromises.get("m.cross_signing.self_signing");
|
||||
});
|
||||
|
||||
newBackendOnly("Should accept the backup decryption key gossip if valid", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeTruthy();
|
||||
expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64);
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, nonMatchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
const infoCopy = Object.assign({}, matchingBackupInfo);
|
||||
delete infoCopy.auth_data.signatures;
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, infoCopy);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(
|
||||
requestId!,
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
unknownAlgorithmBackupInfo,
|
||||
);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept an invalid backup decryption key", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, "InvalidSecret", matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Common test setup for gossiping secrets.
|
||||
* Creates a peer to peer session, sends the secret, mockup the version API, send the secret back from sync, then await for the backup check.
|
||||
*/
|
||||
async function sendBackupGossipAndExpectVersion(
|
||||
requestId: string,
|
||||
secret: string,
|
||||
expectBackup: KeyBackupInfo,
|
||||
) {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
|
||||
const toDeviceEvent = encryptSecretSend({
|
||||
sender: aliceClient.getUserId()!,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
p2pSession: p2pSession,
|
||||
olmAccount: testOlmAccount,
|
||||
requestId: requestId!,
|
||||
secret: secret,
|
||||
});
|
||||
|
||||
const expectBackupCheck = new Promise((resolve) => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/version",
|
||||
(url, request) => {
|
||||
resolve(undefined);
|
||||
return expectBackup;
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", CURVE25519_KEY_BACKUP_DATA);
|
||||
|
||||
// The dummy device sends the secret
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
await expectBackupCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do an interactive verification between alice and the dummy device.
|
||||
*/
|
||||
async function doInteractiveVerification(): Promise<void> {
|
||||
// Do a QR code verification for simplicity
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, olmDeviceId),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can show a QR code
|
||||
returnToDeviceMessageFromSync(
|
||||
buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"], olmDeviceId),
|
||||
);
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
const currentDeviceKey = e2eKeyReceiver.getSigningKey();
|
||||
// the dummy device shows a QR code
|
||||
const sharedSecret = "SUPERSEKRET";
|
||||
// use mode 0x01, self-verifying in which the current device does trust the master key
|
||||
const mode = 0x01;
|
||||
const qrCodeBuffer = buildQRCode(transactionId, usermasterPubKey, currentDeviceKey, sharedSecret, mode);
|
||||
|
||||
// Alice scans the QR code
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
const verifier = await request.scanQRCode(qrCodeBuffer);
|
||||
|
||||
await sendToDevicePromise;
|
||||
|
||||
const verificationPromise = verifier.verify();
|
||||
// the dummy device confirms that Alice scanned the QR code, by replying with a done
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// Alice also replies with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
|
||||
// The other device should now be verified.
|
||||
const otherDevice = (await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]))
|
||||
.get(TEST_USER_ID)!
|
||||
.get(olmDeviceId);
|
||||
expect(otherDevice?.verified).toEqual(DeviceVerification.Verified);
|
||||
}
|
||||
});
|
||||
|
||||
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
@@ -927,6 +1488,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
ev.sender ??= TEST_USER_ID;
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
|
||||
}
|
||||
|
||||
function returnRoomMessageFromSync(roomId: string, ev: IEvent): void {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: { timeline: { events: [ev] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -947,6 +1519,52 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to add all needed mocks for secret requesting (to-device of type `m.secret.request`).
|
||||
*
|
||||
* The following secrets are mocked: `m.cross_signing.master`, `m.cross_signing.self_signing`,
|
||||
* `m.cross_signing.user_signing`, `m.megolm_backup.v1`.
|
||||
*
|
||||
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
|
||||
*/
|
||||
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const mskRequestDefer = defer<string>();
|
||||
const sskRequestDefer = defer<string>();
|
||||
const uskRequestDefer = defer<string>();
|
||||
const backupKeyRequestDefer = defer<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
(url: string, opts: RequestInit): MockResponse => {
|
||||
const messages = JSON.parse(opts.body as string).messages[TEST_USER_ID];
|
||||
// rust crypto broadcasts to all devices, old crypto to a specific device, take the first one
|
||||
const content = Object.values(messages)[0] as any;
|
||||
if (content.action == "request") {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const promiseMap = new Map<string, Promise<string>>();
|
||||
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
/** wait for the verification request to emit a 'Change' event */
|
||||
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -991,12 +1609,16 @@ function buildRequestMessage(transactionId: string): { type: string; content: ob
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.ready to-device message originating from the dummy device */
|
||||
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
|
||||
/** build an m.key.verification.ready to-device message originating from the given `fromDevice` (default to `TEST_DEVICE_ID` if not provided) */
|
||||
function buildReadyMessage(
|
||||
transactionId: string,
|
||||
methods: string[],
|
||||
fromDevice?: string,
|
||||
): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
from_device: fromDevice || TEST_DEVICE_ID,
|
||||
methods: methods,
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
@@ -1094,14 +1716,20 @@ function buildDoneMessage(transactionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array {
|
||||
function buildQRCode(
|
||||
transactionId: string,
|
||||
key1Base64: string,
|
||||
key2Base64: string,
|
||||
sharedSecret: string,
|
||||
mode = 0x02,
|
||||
): Uint8Array {
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
|
||||
const qrCodeBuffer = Buffer.alloc(150); // oversize
|
||||
let idx = 0;
|
||||
idx += qrCodeBuffer.write("MATRIX", idx, "ascii");
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // version
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode
|
||||
idx = qrCodeBuffer.writeUInt8(mode, idx); // mode
|
||||
idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx);
|
||||
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
|
||||
|
||||
|
||||
@@ -92,9 +92,7 @@ describe("MatrixClient events", function () {
|
||||
type: "m.room.create",
|
||||
room: "!erufh:bar",
|
||||
user: "@foo:bar",
|
||||
content: {
|
||||
creator: "@foo:bar",
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -196,6 +194,37 @@ describe("MatrixClient events", function () {
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit User events when presence data is absent in first sync", async () => {
|
||||
const MODIFIED_SYNC_DATA: any = structuredClone(SYNC_DATA);
|
||||
delete MODIFIED_SYNC_DATA["presence"];
|
||||
const MODIFIED_NEXT_SYNC_DATA: any = structuredClone(NEXT_SYNC_DATA);
|
||||
MODIFIED_NEXT_SYNC_DATA.presence = {
|
||||
events: [
|
||||
utils.mkPresence({
|
||||
user: "@foo:bar",
|
||||
name: "Foo Bar",
|
||||
presence: "online",
|
||||
}),
|
||||
],
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_NEXT_SYNC_DATA);
|
||||
let fired = false;
|
||||
client!.on(UserEvent.Presence, function (event, user) {
|
||||
fired = true;
|
||||
expect(user).toBeTruthy();
|
||||
expect(event).toBeTruthy();
|
||||
if (!user || !event) {
|
||||
return;
|
||||
}
|
||||
expect(event.event).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]);
|
||||
expect(user.presence).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]?.content?.presence);
|
||||
});
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit Room events", function () {
|
||||
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
|
||||
|
||||
@@ -107,9 +107,7 @@ const INITIAL_SYNC_DATA = {
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
user: userId,
|
||||
content: {
|
||||
creator: userId,
|
||||
},
|
||||
content: {},
|
||||
event: false,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
describe("joinRoom", function () {
|
||||
it("should no-op if you've already joined a room", function () {
|
||||
it("should no-op given the ID of a room you've already joined", async () => {
|
||||
const roomId = "!foo:bar";
|
||||
const room = new Room(roomId, client, userId);
|
||||
client.fetchRoomEvent = () =>
|
||||
@@ -168,8 +168,32 @@ describe("MatrixClient", function () {
|
||||
]);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
store.storeRoom(room);
|
||||
client.joinRoom(roomId);
|
||||
|
||||
const joinPromise = client.joinRoom(roomId);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
expect(await joinPromise).toBe(room);
|
||||
});
|
||||
|
||||
it("should no-op given the alias of a room you've already joined", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const roomAlias = "#my-fancy-room:server";
|
||||
const room = new Room(roomId, client, userId);
|
||||
room.addLiveEvents([
|
||||
utils.mkMembership({
|
||||
user: userId,
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
event: true,
|
||||
}),
|
||||
]);
|
||||
store.storeRoom(room);
|
||||
|
||||
// The method makes a request to resolve the alias
|
||||
httpBackend.when("POST", "/join/" + encodeURIComponent(roomAlias)).respond(200, { room_id: roomId });
|
||||
|
||||
const joinPromise = client.joinRoom(roomAlias);
|
||||
await httpBackend.flushAllExpected();
|
||||
expect(await joinPromise).toBe(room);
|
||||
});
|
||||
|
||||
it("should send request to inviteSignUrl if specified", async () => {
|
||||
@@ -1180,44 +1204,61 @@ describe("MatrixClient", function () {
|
||||
|
||||
describe("requestLoginToken", () => {
|
||||
it("should hit the expected API endpoint with UIA", async () => {
|
||||
jest.spyOn(client.http, "getUrl");
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
.respond(200, { capabilities: { "m.get_login_token": { enabled: true } } });
|
||||
const response = {};
|
||||
const uiaData = {};
|
||||
const prom = client.requestLoginToken(uiaData);
|
||||
httpBackend
|
||||
.when("POST", "/unstable/org.matrix.msc3882/login/get_token", { auth: uiaData })
|
||||
.respond(200, response);
|
||||
httpBackend.when("POST", "/v1/login/get_token", { auth: uiaData }).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
expect(await prom).toStrictEqual(response);
|
||||
expect(client.http.getUrl).toHaveLastReturnedWith(
|
||||
expect.objectContaining({
|
||||
href: "http://alice.localhost.test.server/_matrix/client/v1/login/get_token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should hit the expected API endpoint without UIA", async () => {
|
||||
jest.spyOn(client.http, "getUrl");
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
.respond(200, { capabilities: { "m.get_login_token": { enabled: true } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client.requestLoginToken();
|
||||
httpBackend.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
httpBackend.when("POST", "/v1/login/get_token", {}).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
expect(client.http.getUrl).toHaveLastReturnedWith(
|
||||
expect.objectContaining({
|
||||
href: "http://alice.localhost.test.server/_matrix/client/v1/login/get_token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should hit the r1 endpoint when capability is disabled", async () => {
|
||||
it("should still hit the stable endpoint when capability is disabled (but present)", async () => {
|
||||
jest.spyOn(client.http, "getUrl");
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: false } } });
|
||||
.respond(200, { capabilities: { "m.get_login_token": { enabled: false } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client.requestLoginToken();
|
||||
httpBackend.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
httpBackend.when("POST", "/v1/login/get_token", {}).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
expect(client.http.getUrl).toHaveLastReturnedWith(
|
||||
expect.objectContaining({
|
||||
href: "http://alice.localhost.test.server/_matrix/client/v1/login/get_token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should hit the r0 endpoint for fallback", async () => {
|
||||
jest.spyOn(client.http, "getUrl");
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {});
|
||||
const response = { login_token: "xyz", expires_in: 5 };
|
||||
const prom = client.requestLoginToken();
|
||||
@@ -1225,6 +1266,11 @@ describe("MatrixClient", function () {
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r1
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in_ms: 5000 });
|
||||
expect(client.http.getUrl).toHaveLastReturnedWith(
|
||||
expect.objectContaining({
|
||||
href: "http://alice.localhost.test.server/_matrix/client/unstable/org.matrix.msc3882/login/token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1989,7 +2035,6 @@ const buildEventCreate = () =>
|
||||
new MatrixEvent({
|
||||
age: 80126105,
|
||||
content: {
|
||||
creator: "@andybalaam-test1:matrix.org",
|
||||
room_version: "6",
|
||||
},
|
||||
event_id: "$e7j2Gt37k5NPwB6lz2N3V9lO5pUdNK8Ai7i2FPEK-oI",
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Copyright 2023 Holi Moli GmbH
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MatrixClient, ClientEvent, createClient, SyncState } from "../../src";
|
||||
|
||||
const makeQueryablePromise = <T = void>(promise: Promise<T>) => {
|
||||
let resolved = false;
|
||||
let rejected = false;
|
||||
|
||||
// Observe the promise, saving the fulfillment in a closure scope.
|
||||
const newPromise = promise.then(
|
||||
(value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
rejected = true;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const isFulfilled = () => {
|
||||
return resolved || rejected;
|
||||
};
|
||||
const isResolved = () => {
|
||||
return resolved;
|
||||
};
|
||||
const isRejected = () => {
|
||||
return rejected;
|
||||
};
|
||||
return { promise: newPromise, isFulfilled, isResolved, isRejected };
|
||||
};
|
||||
|
||||
const queryablePromise = <T = void>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: any) => void;
|
||||
|
||||
const promise = makeQueryablePromise<T>(
|
||||
new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
}),
|
||||
);
|
||||
|
||||
return { resolve, reject, ...promise };
|
||||
};
|
||||
|
||||
describe("MatrixClient syncing errors", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
const unknownTokenErrorData = {
|
||||
status: 401,
|
||||
body: {
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
error: "Invalid access token passed.",
|
||||
soft_logout: false,
|
||||
},
|
||||
};
|
||||
let client: MatrixClient | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({
|
||||
baseUrl: "http://tocal.test.server",
|
||||
userId: selfUserId,
|
||||
accessToken: selfAccessToken,
|
||||
deviceId: "myDevice",
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry, until errors are solved.", async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
|
||||
.getOnce("end:versions", 429) // second version check fails with 429 triggering another retry
|
||||
.get("end:versions", {}) // further version checks succeed
|
||||
.getOnce("end:pushrules/", 429) // first pushrules check fails starting retry
|
||||
.get("end:pushrules/", {}) // further pushrules check succeed
|
||||
.catch({}); // all other calls succeed
|
||||
|
||||
const syncEvents = Array.from({ length: 5 }, queryablePromise<SyncState>);
|
||||
|
||||
client!.on(ClientEvent.Sync, (state: SyncState, lastState: SyncState | null) => {
|
||||
let i = 0;
|
||||
for (; i < syncEvents.length && syncEvents[i].isFulfilled(); i++) {
|
||||
// find index of first unfulfilled promise
|
||||
}
|
||||
syncEvents[i].resolve(state);
|
||||
});
|
||||
|
||||
await client!.startClient();
|
||||
expect(await syncEvents[0].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[1].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
|
||||
});
|
||||
|
||||
it("should stop sync keep alive when client is stopped.", async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
|
||||
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
|
||||
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
|
||||
.post("end:logout", unknownTokenErrorData) // just to keep up a consistent scenario. Does not have a real effect for this testcase
|
||||
.post("end:filter", 401); // just to keep up a consistent scenario. Does not have a real effect for this testcase
|
||||
|
||||
const firstSyncEvent = queryablePromise<SyncState>();
|
||||
const secondSyncEvent = queryablePromise<SyncState>();
|
||||
client!.on(ClientEvent.Sync, (state: SyncState, lastState: SyncState | null) => {
|
||||
if (firstSyncEvent.isFulfilled()) secondSyncEvent.resolve(state);
|
||||
firstSyncEvent.resolve(state);
|
||||
});
|
||||
|
||||
await client!.startClient();
|
||||
const logoutDone = queryablePromise();
|
||||
client!
|
||||
.logout(true)
|
||||
.then(() => {
|
||||
logoutDone.resolve();
|
||||
})
|
||||
.catch((e) => {
|
||||
logoutDone.resolve();
|
||||
});
|
||||
|
||||
const syntState = await firstSyncEvent.promise;
|
||||
expect(syntState).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive
|
||||
|
||||
jest.useRealTimers(); // we need real timer for the setTimout below to work
|
||||
|
||||
const timeoutPromise = makeQueryablePromise(new Promise<void>((res) => setTimeout(res, 1)));
|
||||
|
||||
await Promise.race([secondSyncEvent.promise, timeoutPromise.promise]);
|
||||
// when syncing stopped, then the secondSyncEvent will never happen and the promise will not be resolved,
|
||||
/// so the timeoutPromise will be resolved instead
|
||||
expect(timeoutPromise.isFulfilled()).toBe(true);
|
||||
expect(secondSyncEvent.isFulfilled()).toBe(false);
|
||||
|
||||
await logoutDone.promise; // wait for the logout to finish to prevent processing and logging after the test is done.
|
||||
});
|
||||
});
|
||||
@@ -223,6 +223,121 @@ describe("MatrixClient syncing", () => {
|
||||
expect(fires).toBe(3);
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -293,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"));
|
||||
@@ -358,6 +513,7 @@ describe("MatrixClient syncing", () => {
|
||||
join: {},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -389,9 +545,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -577,9 +731,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -611,9 +763,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomTwo,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -758,7 +908,6 @@ describe("MatrixClient syncing", () => {
|
||||
room: roomOne,
|
||||
user: otherUserId,
|
||||
content: {
|
||||
creator: otherUserId,
|
||||
room_version: "9",
|
||||
},
|
||||
});
|
||||
@@ -844,7 +993,6 @@ describe("MatrixClient syncing", () => {
|
||||
room: roomOne,
|
||||
user: otherUserId,
|
||||
content: {
|
||||
creator: otherUserId,
|
||||
room_version: testMeta.roomVersion,
|
||||
},
|
||||
});
|
||||
@@ -1372,9 +1520,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
} as Partial<IJoinedRoom>,
|
||||
@@ -1471,9 +1617,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomOne,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -1629,9 +1773,7 @@ describe("MatrixClient syncing", () => {
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } }, ""),
|
||||
{
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { logger } from "../src/logger";
|
||||
// try to load the olm library.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
global.Olm = require("@matrix-org/olm");
|
||||
globalThis.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
|
||||
@@ -16,6 +16,8 @@ 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()`.
|
||||
*
|
||||
@@ -56,3 +58,35 @@ export function mockSetupCrossSigningRequests(): void {
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,10 +26,15 @@ python -m venv env
|
||||
|
||||
import base64
|
||||
import json
|
||||
import base58
|
||||
|
||||
from canonicaljson import encode_canonical_json
|
||||
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
|
||||
|
||||
from random import randbytes, seed
|
||||
|
||||
ALICE_DATA = {
|
||||
@@ -38,6 +43,8 @@ ALICE_DATA = {
|
||||
"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",
|
||||
@@ -55,6 +62,8 @@ BOB_DATA = {
|
||||
"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",
|
||||
@@ -75,8 +84,8 @@ def main() -> None:
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
import {{ KeyBackupInfo }} from "../../../src/crypto-api";
|
||||
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
|
||||
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -97,6 +106,11 @@ 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)
|
||||
)
|
||||
@@ -164,9 +178,10 @@ def build_test_data(user_data, prefix = "") -> str:
|
||||
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": sig}
|
||||
}
|
||||
|
||||
set_of_exported_room_keys = [build_exported_megolm_key(), build_exported_megolm_key()]
|
||||
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 = build_exported_megolm_key()
|
||||
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']
|
||||
@@ -186,6 +201,13 @@ def build_test_data(user_data, prefix = "") -> str:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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']}";
|
||||
@@ -220,9 +242,15 @@ 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) };
|
||||
|
||||
@@ -236,8 +264,19 @@ export const {prefix}MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
json.dumps(additional_exported_room_key, indent=4)
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
/** 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)};
|
||||
"""
|
||||
|
||||
|
||||
@@ -334,10 +373,11 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
|
||||
return signature_base64
|
||||
|
||||
def build_exported_megolm_key() -> dict:
|
||||
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))
|
||||
@@ -353,8 +393,10 @@ def build_exported_megolm_key() -> dict:
|
||||
|
||||
megolm_export = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"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)
|
||||
),
|
||||
@@ -365,8 +407,248 @@ def build_exported_megolm_key() -> dict:
|
||||
"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()
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult } from "../../../src";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { IDownloadKeyResult, IEvent } from "../../../src";
|
||||
import { KeyBackupSession, KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -102,9 +102,28 @@ 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",
|
||||
@@ -123,8 +142,8 @@ export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"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": {
|
||||
@@ -134,8 +153,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "mPYSGA2l1tOQiipEDEVYhDSdTSFh2lDW1qpGKYZRxTc",
|
||||
"session_key": "AQAAAAAHwgkB49BTPAEGTCK6degxUIbl8GPG2ugPRYhNtOpNic63u11+baXFfjDw5fmVfD1gJXpQQjGsqrIYioxrB1xzl7mfb942UHhYdaMQZowpp1fSpJVsxR5TddUU2EWifYD9EQsoz8mY1zqoazm4vUP4v9yxaTcUBj2c6HMJCY0gCJj2EhgNpdbTkIoqRAxFWIQ0nU0hYdpQ1taqRimGUcU3",
|
||||
"sender_claimed_keys": {
|
||||
@@ -148,8 +167,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
/** An exported megolm session */
|
||||
export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"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": {
|
||||
@@ -158,22 +177,58 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const ONE_TIME_KEYS = {
|
||||
"@alice:localhost": {
|
||||
"test_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "25djC6Rk6gIgFBMVawY9X9LnY8XMMziey6lKqL8Q5Bbp7T1vw9uk0RE7eKO2a/jNLcYroO2xRztGhBrKz5sOCQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 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";
|
||||
@@ -267,9 +322,28 @@ export const BOB_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
}
|
||||
};
|
||||
|
||||
/** 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",
|
||||
@@ -288,23 +362,23 @@ export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "X9SK5JceUdvUr9gZkmdfS78qmKztoL60oORJ9JA5XD8",
|
||||
"session_key": "AQAAAADbfOdUj/ec5bK4xVEhhRw+jfd1FD4uA0MLy7NyVHugZxyXNZUx5YEof4H9YyjBretviZreMSXqflbgYKz257rkKu7MPeKFf7zmln2GxX0F/p++GOnvpY1FqOglhfRQi3tqiyOa7SL4f7TuERDTOpMqlWhIfTKQnqy0AyF2vpDi5V/UiuSXHlHb1K/YGZJnX0u/Kpis7aC+tKDkSfSQOVw/",
|
||||
"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": "ZG6lrfATe+958wN1xaGf3dKG/CThEfkmNdp1jcu4zok"
|
||||
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA",
|
||||
"session_key": "AQAAAACv0khqPrQ91MmWCgm0RTzfpn65AGCrRnAKLxGJdfSfECNZ8gyj34FZLwi+F+xC6ibFddcbLXW0mzR6PnTnHF3VHM4g/h+2rcxtlix8fySpIwFzaXViba7cOSy/b+dHTMZB40iA7F4y7AdTdHLv4N1XUj3puU/KVUIKf9/lEDLqyReD+39WdEY24mTIB5NcQQhtyguPzYPT5sSyeIUNd7Bw",
|
||||
"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": "HxUKnGfeUu0fF3cLyCFSDXYtVCQHy/+33q9RkzKfsiU"
|
||||
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
}
|
||||
@@ -313,29 +387,65 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
/** An exported megolm session */
|
||||
export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM",
|
||||
"session_key": "AQAAAACvcoGk7mOY59fOqZaxFUiTCBRV1Ia94KBjAZx6kgdgBtkkvs50z8od8/Nc9ncK2UsEiXNvCTTp2dlN3du+Rx0/m7vet2ZOEEp2oYDjHMLLFmwd1gtlGuWYPdXA6Y1+9Yyph0/EDVfS+zd3XvbL0QgbyL43+yQnFNHKlxVJX1eiKTrGTHQtYEOZz6/i/bbk+sV7GhSZFT5IMT9hXsRxdf0D",
|
||||
"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": "dV0TIhhkToXpL+gZLo+zXDHJfw7MWYxpg80cynIQDv0"
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const BOB_ONE_TIME_KEYS = {
|
||||
"@bob:xyz": {
|
||||
"bob_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "dlZc9VA/hP980Mxvu9qwi0qJx8VK7sADGOM48CE01YM7K/Mbty9lis/QjtQAWqDg371QyynVRjEzt9qj7eSFCg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 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
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
|
||||
join: { [roomId]: roomResponse },
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
@@ -520,15 +521,22 @@ 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
|
||||
app_display_name: "app",
|
||||
app_id: "123",
|
||||
@@ -551,3 +559,25 @@ CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
|
||||
if (global.Olm) {
|
||||
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto();
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
/**
|
||||
* Advance the fake timers in a loop until the given promise resolves or rejects.
|
||||
*
|
||||
* Returns the result of the promise.
|
||||
*
|
||||
* This can be useful when there are multiple steps in the code which require an iteration of the event loop.
|
||||
*/
|
||||
export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
|
||||
let resolved = false;
|
||||
promise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
while (!resolved) {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { TextEncoder, TextDecoder } from "util";
|
||||
import NodeBuffer from "node:buffer";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
|
||||
|
||||
describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
|
||||
let origBuffer = Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
if (env === "browser") {
|
||||
origBuffer = Buffer;
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = undefined;
|
||||
|
||||
global.atob = NodeBuffer.atob;
|
||||
global.btoa = NodeBuffer.btoa;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = origBuffer;
|
||||
// @ts-ignore
|
||||
global.atob = undefined;
|
||||
// @ts-ignore
|
||||
global.btoa = undefined;
|
||||
});
|
||||
|
||||
it("Should decode properly encoded data", async () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));
|
||||
|
||||
expect(decoded).toStrictEqual("encoding hello world");
|
||||
});
|
||||
|
||||
it("Should decode URL-safe base64", async () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
|
||||
|
||||
expect(decoded).toStrictEqual("?????");
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+137
-3
@@ -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;
|
||||
|
||||
@@ -110,14 +115,25 @@ describe("Crypto", function () {
|
||||
expect(Crypto.getOlmVersion()[0]).toEqual(3);
|
||||
});
|
||||
|
||||
it("getVersion() should return the current version of the olm library", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
expect(client.getCrypto()?.getVersion()).toBe(
|
||||
`Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("encrypted events", function () {
|
||||
it("provides encryption information", 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 +143,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 +159,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 +178,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 +193,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.UNSIGNED_DEVICE,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -982,7 +1116,7 @@ describe("Crypto", function () {
|
||||
|
||||
describe("Secret storage", function () {
|
||||
it("creates secret storage even if there is no keyInfo", async function () {
|
||||
jest.spyOn(logger, "log").mockImplementation(() => {});
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
jest.setTimeout(10000);
|
||||
const client = new TestClient("@a:example.com", "dev").client;
|
||||
await client.initCrypto();
|
||||
|
||||
@@ -215,6 +215,36 @@ describe("MegolmBackup", function () {
|
||||
jest.spyOn(global, "setTimeout").mockRestore();
|
||||
});
|
||||
|
||||
test("fail if crypto not enabled", async () => {
|
||||
const client = makeTestClient(cryptoStore);
|
||||
const data = {
|
||||
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
await expect(client.restoreKeyBackupWithSecretStorage(data)).rejects.toThrow(
|
||||
"End-to-end encryption disabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("fail if given backup has no version", async () => {
|
||||
const client = makeTestClient(cryptoStore);
|
||||
await client.initCrypto();
|
||||
const data = {
|
||||
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1");
|
||||
await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow(
|
||||
"Backup version must be defined",
|
||||
);
|
||||
});
|
||||
|
||||
it("automatically calls the key back up", function () {
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { ISignatures } from "../../../src/@types/signed";
|
||||
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
|
||||
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
@@ -275,13 +276,13 @@ describe("Secrets", function () {
|
||||
|
||||
describe("bootstrap", function () {
|
||||
// keys used in some of the tests
|
||||
const XSK = new Uint8Array(olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0";
|
||||
const USK = new Uint8Array(olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU";
|
||||
const SSK = new Uint8Array(olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q";
|
||||
const SSSSKey = new Uint8Array(olmlib.decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
|
||||
it("bootstraps when no storage or cross-signing keys locally", async function () {
|
||||
const key = new Uint8Array(16);
|
||||
@@ -312,6 +313,7 @@ describe("Secrets", function () {
|
||||
this.emit(ClientEvent.AccountData, event);
|
||||
return {};
|
||||
};
|
||||
bob.getKeyBackupVersion = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await bob.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (func) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import "../../../olm-loader";
|
||||
import { MatrixClient, MatrixEvent } from "../../../../src/matrix";
|
||||
import { encodeBase64 } from "../../../../src/crypto/olmlib";
|
||||
import { encodeBase64 } from "../../../../src/base64";
|
||||
import "../../../../src/crypto"; // import this to cycle-break
|
||||
import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning";
|
||||
import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("eventMapperFor", function () {
|
||||
getRoom(roomId: string): Room | null {
|
||||
return rooms.find((r) => r.roomId === roomId) ?? null;
|
||||
},
|
||||
setUserCreator(_) {},
|
||||
} as IStore,
|
||||
scheduler: {
|
||||
setProcessFunction: jest.fn(),
|
||||
|
||||
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
|
||||
import {
|
||||
ClientPrefix,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IdentityPrefix,
|
||||
IHttpOpts,
|
||||
MatrixError,
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, QueryDict } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -231,13 +239,145 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
describe("authedRequest", () => {
|
||||
it("should not include token if unset", () => {
|
||||
const fetchFn = jest.fn();
|
||||
it("should not include token if unset", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
|
||||
api.authedRequest(Method.Post, "/account/password");
|
||||
await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("with refresh token", () => {
|
||||
const accessToken = "test-access-token";
|
||||
const refreshToken = "test-refresh-token";
|
||||
|
||||
describe("when an unknown token error is encountered", () => {
|
||||
const unknownTokenErrBody = {
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
error: "Token is not active",
|
||||
soft_logout: false,
|
||||
};
|
||||
const unknownTokenErr = new MatrixError(unknownTokenErrBody, 401);
|
||||
const unknownTokenResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
return name === "Content-Type" ? "application/json" : null;
|
||||
},
|
||||
},
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(unknownTokenErrBody)),
|
||||
};
|
||||
const okayResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
};
|
||||
|
||||
describe("without a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw when token refresh fails", async () => {
|
||||
const error = new Error("uh oh");
|
||||
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should refresh token and retry request", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(unknownTokenResponse)
|
||||
.mockResolvedValueOnce(okayResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token on retry
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl()", () => {
|
||||
@@ -300,22 +440,29 @@ describe("FetchHttpApi", () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
logger: mockLogger,
|
||||
});
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(logger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mocked(logger.debug).mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.debug.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
|
||||
]
|
||||
`);
|
||||
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
|
||||
expect(mockLogger.debug.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
|
||||
]
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("InteractiveAuth", () => {
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should fire stateUpdated callback if with error when encountered", async () => {
|
||||
it("should fire stateUpdated callback with error when a request fails", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
@@ -578,15 +578,20 @@ describe("InteractiveAuth", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
// StateUpdated should be called. We call submitAuthDict() to trigger a request ...
|
||||
let firstTime = true;
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
// Only trigger the request the first time, to avoid an infinite loop
|
||||
if (firstTime) {
|
||||
firstTime = false;
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
// .. 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()]);
|
||||
|
||||
@@ -328,6 +328,7 @@ describe("MatrixClient", function () {
|
||||
"storeFilter",
|
||||
"startup",
|
||||
"deleteAllData",
|
||||
"setUserCreator",
|
||||
] as const
|
||||
).reduce((r, k) => {
|
||||
r[k] = jest.fn();
|
||||
@@ -2266,7 +2267,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,138 @@
|
||||
/*
|
||||
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,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
getSender: jest.fn().mockReturnValue("@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,412 @@
|
||||
/*
|
||||
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,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
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].membershipID).toEqual("bloop");
|
||||
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" }],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@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,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@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],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@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,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -675,6 +675,69 @@ describe("Thread", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEvent", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds events even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(message2);
|
||||
expect(secondLastEvent).toBe(message1);
|
||||
});
|
||||
|
||||
it("Adds events to start even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, true);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const firstEvent = thread.timeline.at(0)!;
|
||||
expect(lastEvent).toBe(message1);
|
||||
expect(firstEvent).toBe(message2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,6 +134,25 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should generate url with create prompt", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const metadata = delegatedAuthConfig.metadata;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
prompt: "create",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("prompt")).toEqual("create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
@@ -284,6 +303,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +345,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { OidcTokenRefresher } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
describe("OidcTokenRefresher", () => {
|
||||
// OidcTokenRefresher props
|
||||
// see class declaration for info
|
||||
const authConfig = {
|
||||
issuer: "https://issuer.org/",
|
||||
};
|
||||
const clientId = "test-client-id";
|
||||
const redirectUri = "https://test.org";
|
||||
const deviceId = "abc123";
|
||||
const idTokenClaims = {
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
aud: clientId,
|
||||
iss: authConfig.issuer,
|
||||
sub: "123",
|
||||
iat: 123,
|
||||
};
|
||||
// used to mock a valid token response, as consumed by OidcClient library
|
||||
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||
|
||||
// auth config used in mocked calls to OP .well-known
|
||||
const config = makeDelegatedAuthConfig(authConfig.issuer);
|
||||
|
||||
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 300,
|
||||
scope: scope,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
fetchMock.post(config.metadata.token_endpoint, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("new-access-token", "new-refresh-token"),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetchMock.resetBehavior();
|
||||
});
|
||||
|
||||
it("throws when oidc client cannot be initialised", async () => {
|
||||
jest.spyOn(logger, "error");
|
||||
fetchMock.get(
|
||||
`${config.metadata.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.oidcClientReady).rejects.toThrow();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to initialise OIDC client.",
|
||||
// error from OidcClient
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("initialises oidc client", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
// @ts-ignore peek at private property to see we initialised the client correctly
|
||||
expect(refresher.oidcClient.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: authConfig.issuer,
|
||||
scope,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("doRefreshAccessToken()", () => {
|
||||
it("should throw when oidcClient has not been initialised", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
||||
"Cannot get new token before OIDC client is initialised.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh the tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
const result = await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// spy on our stub
|
||||
jest.spyOn(refresher, "persistTokens");
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
const refreshToken = "refresh-token";
|
||||
const first = refresher.doRefreshAccessToken(refreshToken);
|
||||
const second = refresher.doRefreshAccessToken(refreshToken);
|
||||
|
||||
const result1 = await second;
|
||||
const result2 = await first;
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
// call again after first request resolves
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
fetchMock.post(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should make fresh request after a failed request", async () => {
|
||||
// make sure inflight request is cleared after a failure
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// first call fails
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
|
||||
// call again after first request resolves
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { EventType, MatrixEvent, Room } from "../../src/matrix";
|
||||
import { EventType, MatrixEvent, RelationType, Room, threadIdForReceipt } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -258,4 +258,200 @@ describe("Read receipt", () => {
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Determining the right thread ID for a receipt", () => {
|
||||
it("provides the thread root ID for a normal threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"body": "Hello from a thread",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread1",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread1",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for a non-thread message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
// Set thread ID to this event's ID, meaning this is the thread root
|
||||
event.setThreadId(event.getId());
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a reaction to a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$thread1",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, meaning this looks like it's in the thread (this
|
||||
// happens for relations like this, so that they appear in the
|
||||
// thread's timeline).
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// But because it's a reaction to the thread root, it's in main
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for a reaction to a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$withinthread2",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread. This happens
|
||||
// when the message arrived and is classified.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread because it refers to something else, not the
|
||||
// thread root
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("(suprisingly?) provides 'main' for a redaction of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$withinthread2",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// Because redacting a message removes all its m.relations, the
|
||||
// message is no longer in the thread, so we must send a receipt for
|
||||
// it in the main timeline.
|
||||
//
|
||||
// This is surprising, but it follows the spec (at least up to
|
||||
// current latest room version, 11). In fact, the event should no
|
||||
// longer have a thread ID set on it, so this testcase should not
|
||||
// come up. (At time of writing, this is not the case though - it
|
||||
// does still have threadId set.)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for an edit of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$withinthread2",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for an edit of a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$thread1",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a redaction of the thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$thread1",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
function makeTransport(name: string) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
MSC3886SimpleHttpRendezvousTransportDetails,
|
||||
} from "../../../src/rendezvous/transports";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
|
||||
@@ -37,7 +37,7 @@ function makeMockClient(opts: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
deviceKey?: string;
|
||||
msc3882Enabled: boolean;
|
||||
getLoginTokenEnabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
msc3886Enabled: boolean;
|
||||
devices?: Record<string, Partial<DeviceInfo>>;
|
||||
@@ -54,7 +54,7 @@ function makeMockClient(opts: {
|
||||
getVersions() {
|
||||
return {
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": opts.msc3882Enabled,
|
||||
"org.matrix.msc3882": opts.getLoginTokenEnabled,
|
||||
"org.matrix.msc3886": opts.msc3886Enabled,
|
||||
},
|
||||
};
|
||||
@@ -64,8 +64,8 @@ function makeMockClient(opts: {
|
||||
? {}
|
||||
: {
|
||||
capabilities: {
|
||||
"org.matrix.msc3882.get_login_token": {
|
||||
enabled: opts.msc3882Enabled,
|
||||
"m.get_login_token": {
|
||||
enabled: opts.getLoginTokenEnabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -122,7 +122,7 @@ describe("Rendezvous", function () {
|
||||
userId: "@alice:example.com",
|
||||
deviceId: "DEVICEID",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: true,
|
||||
});
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
@@ -180,10 +180,10 @@ describe("Rendezvous", function () {
|
||||
});
|
||||
|
||||
async function testNoProtocols({
|
||||
msc3882Enabled,
|
||||
getLoginTokenEnabled,
|
||||
msc3882r0Only,
|
||||
}: {
|
||||
msc3882Enabled: boolean;
|
||||
getLoginTokenEnabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
}) {
|
||||
const aliceTransport = makeTransport("Alice");
|
||||
@@ -198,7 +198,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled,
|
||||
getLoginTokenEnabled,
|
||||
msc3882r0Only,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -241,11 +241,11 @@ describe("Rendezvous", function () {
|
||||
}
|
||||
|
||||
it("no protocols - r0", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
|
||||
await testNoProtocols({ getLoginTokenEnabled: false, msc3882r0Only: true });
|
||||
});
|
||||
|
||||
it("no protocols - r1", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
|
||||
it("no protocols - stable", async function () {
|
||||
await testNoProtocols({ getLoginTokenEnabled: false, msc3882r0Only: false });
|
||||
});
|
||||
|
||||
it("new device declines protocol with outcome unsupported", async function () {
|
||||
@@ -260,7 +260,7 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -319,7 +319,7 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -378,7 +378,7 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -439,7 +439,7 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -508,7 +508,7 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
devices,
|
||||
|
||||
@@ -70,9 +70,7 @@ describe("RoomState", function () {
|
||||
user: userA,
|
||||
room: roomId,
|
||||
event: true,
|
||||
content: {
|
||||
creator: userA,
|
||||
},
|
||||
content: {},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
+116
-4
@@ -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)]));
|
||||
@@ -3471,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",
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("CrossSigningIdentity", () => {
|
||||
olmMachine = {
|
||||
crossSigningStatus: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
exportCrossSigningKeys: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
@@ -50,9 +51,11 @@ describe("CrossSigningIdentity", () => {
|
||||
|
||||
secretStorage = {
|
||||
get: jest.fn(),
|
||||
hasKey: jest.fn(),
|
||||
store: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage, jest.fn());
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
@@ -61,7 +64,8 @@ describe("CrossSigningIdentity", () => {
|
||||
hasSelfSigning: true,
|
||||
hasUserSigning: true,
|
||||
});
|
||||
// TODO: secret storage
|
||||
// in secret storage
|
||||
secretStorage.get.mockResolvedValue("base64-saved-in-storage");
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
@@ -73,6 +77,19 @@ describe("CrossSigningIdentity", () => {
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("Shoud update 4S on reset if 4S is set up", async () => {
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
secretStorage.hasKey.mockResolvedValue(true);
|
||||
olmMachine.exportCrossSigningKeys.mockResolvedValue({
|
||||
masterKey: "base64_aaaaaaaaaa",
|
||||
self_signing_key: "base64_bbbbbbbbbbb",
|
||||
userSigningKey: "base64_cccccccc",
|
||||
});
|
||||
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
expect(secretStorage.store).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if we need new keys", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: false,
|
||||
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
} 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 */
|
||||
@@ -81,12 +82,6 @@ describe("OutgoingRequestProcessor", () => {
|
||||
"https://example.com/_matrix/client/v3/keys/signatures/upload",
|
||||
],
|
||||
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
[
|
||||
"SigningKeysUploadRequest",
|
||||
SigningKeysUploadRequest,
|
||||
"POST",
|
||||
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
|
||||
],
|
||||
];
|
||||
|
||||
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
|
||||
@@ -120,7 +115,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
|
||||
it("should handle ToDeviceRequests", async () => {
|
||||
// first, mock up the ToDeviceRequest as we might expect to receive it from the Rust layer ...
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const testBody = '{ "messages": { "user": {"device": "bar" }}}';
|
||||
const outgoingRequest = new ToDeviceRequest("1234", "test/type", "test/txnid", testBody);
|
||||
|
||||
// ... then poke it into the OutgoingRequestProcessor under test.
|
||||
@@ -178,10 +173,37 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests without UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest(JSON.stringify(testReq));
|
||||
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||
|
||||
// Now: check that it makes a matching HTTP request.
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("POST", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
|
||||
expect(JSON.parse(req.rawData)).toEqual(testReq);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests with UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
|
||||
const outgoingRequest = new SigningKeysUploadRequest(JSON.stringify(testReq));
|
||||
|
||||
// also create a UIA callback
|
||||
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
|
||||
@@ -191,7 +213,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
// Now: check that it makes a matching HTTP request.
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("POST", "/_matrix")
|
||||
@@ -203,12 +225,10 @@ describe("OutgoingRequestProcessor", () => {
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
@@ -218,4 +238,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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* /
|
||||
*/
|
||||
|
||||
import { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { HistoryVisibility } from "../../../src";
|
||||
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
|
||||
it.each([
|
||||
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
|
||||
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
|
||||
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
|
||||
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
|
||||
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
|
||||
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IHttpOpts,
|
||||
IToDeviceEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixHttpApi,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
@@ -38,17 +39,107 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { CryptoCallbacks, ImportRoomKeysOpts, VerificationRequest } from "../../../src/crypto-api";
|
||||
import {
|
||||
CryptoCallbacks,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
VerificationRequest,
|
||||
} from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initRustCrypto", () => {
|
||||
function makeTestOlmMachine(): Mocked<OlmMachine> {
|
||||
return {
|
||||
registerRoomKeyUpdatedCallback: jest.fn(),
|
||||
registerUserIdentityUpdatedCallback: jest.fn(),
|
||||
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
|
||||
deleteSecretsFromInbox: jest.fn(),
|
||||
registerReceiveSecretCallback: jest.fn(),
|
||||
outgoingRequests: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
|
||||
it("passes through the store params", async () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
|
||||
expect(OlmMachine.initialize).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase if storePrefix is unset", async () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
null,
|
||||
"storePassphrase",
|
||||
);
|
||||
|
||||
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
|
||||
});
|
||||
|
||||
it("Should get secrets from inbox on start", async () => {
|
||||
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
|
||||
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
it("getVersion() should return the current version of the rust sdk and vodozemac", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const versions = RustSdkCryptoJs.getVersions();
|
||||
expect(rustCrypto.getVersion()).toBe(
|
||||
`Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
@@ -150,6 +241,46 @@ describe("RustCrypto", () => {
|
||||
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),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("key"),
|
||||
} 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),
|
||||
getDefaultKeyId: 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 = {
|
||||
@@ -162,7 +293,10 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
@@ -214,6 +348,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
@@ -236,6 +371,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", "{}")]);
|
||||
@@ -309,6 +469,128 @@ 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(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
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("should handle decryption failures", async () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "fake_alg" },
|
||||
room: "!room:id",
|
||||
});
|
||||
event.event.event_id = "$event:id";
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: () => {
|
||||
throw new Error("UISI");
|
||||
},
|
||||
};
|
||||
await event.attemptDecryption(mockCryptoBackend as unknown as CryptoBackend);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(event);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the event into the OlmMachine", async () => {
|
||||
const encryptedEvent = await makeEncryptedEvent();
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(encryptedEvent);
|
||||
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: undefined }),
|
||||
} 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([
|
||||
[undefined, 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;
|
||||
|
||||
@@ -391,6 +673,7 @@ describe("RustCrypto", () => {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -460,6 +743,23 @@ describe("RustCrypto", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -539,10 +839,23 @@ describe("RustCrypto", () => {
|
||||
it("can save and restore a key", async () => {
|
||||
const key = "testtesttesttesttesttesttesttest";
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key));
|
||||
await rustCrypto.storeSessionBackupPrivateKey(
|
||||
new TextEncoder().encode(key),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
const fetched = await rustCrypto.getSessionBackupPrivateKey();
|
||||
expect(new TextDecoder().decode(fetched!)).toEqual(key);
|
||||
});
|
||||
|
||||
it("fails to save a key if version not provided", async () => {
|
||||
const key = "testtesttesttesttesttesttesttest";
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(() => rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key))).rejects.toThrow(
|
||||
"storeSessionBackupPrivateKey: version is required",
|
||||
);
|
||||
const fetched = await rustCrypto.getSessionBackupPrivateKey();
|
||||
expect(fetched).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveSessionBackupVersion", () => {
|
||||
@@ -569,6 +882,95 @@ describe("RustCrypto", () => {
|
||||
).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(
|
||||
logger,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key backup", () => {
|
||||
it("is started when rust crypto is created", async () => {
|
||||
// `RustCrypto.checkKeyBackupAndEnable` async call is made in background in the RustCrypto constructor.
|
||||
// We don't have an instance of the rust crypto yet, we spy directly in the prototype.
|
||||
const spyCheckKeyBackupAndEnable = jest
|
||||
.spyOn(RustCrypto.prototype, "checkKeyBackupAndEnable")
|
||||
.mockResolvedValue({} as KeyBackupCheck);
|
||||
|
||||
await makeTestRustCrypto();
|
||||
|
||||
expect(spyCheckKeyBackupAndEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises KeyBackupStatus event when identify change", async () => {
|
||||
// Return the key backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
const olmMachine = {
|
||||
getIdentity: jest.fn(),
|
||||
// Force the backup to be trusted by the olmMachine
|
||||
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(true) }),
|
||||
isBackupEnabled: jest.fn().mockReturnValue(true),
|
||||
getBackupKeys: jest.fn(),
|
||||
enableBackupV1: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
mockHttpApi,
|
||||
testData.TEST_USER_ID,
|
||||
testData.TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
|
||||
// Wait for the key backup to be available
|
||||
const keyBackupStatusPromise = new Promise<boolean>((resolve) =>
|
||||
rustCrypto.once(CryptoEvent.KeyBackupStatus, resolve),
|
||||
);
|
||||
await rustCrypto.onUserIdentityUpdated(new RustSdkCryptoJs.UserId(testData.TEST_USER_ID));
|
||||
expect(await keyBackupStatusPromise).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
@@ -582,5 +984,5 @@ async function makeTestRustCrypto(
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks, null);
|
||||
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { secretStorageContainsCrossSigningKeys } from "../../../src/rust-crypto/secret-storage";
|
||||
import {
|
||||
secretStorageCanAccessSecrets,
|
||||
secretStorageContainsCrossSigningKeys,
|
||||
} from "../../../src/rust-crypto/secret-storage";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
|
||||
describe("secret-storage", () => {
|
||||
@@ -22,6 +25,7 @@ describe("secret-storage", () => {
|
||||
it("should return false when the master cross-signing key is not stored in secret storage", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockReturnValue(false),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("SFQ3TbqGOdaaRVfxHtNkn0tvhx0rVj9S"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
@@ -35,6 +39,7 @@ describe("secret-storage", () => {
|
||||
if (type === "m.cross_signing.master") return { secretStorageKey: {} };
|
||||
else return { secretStorageKey2: {} };
|
||||
},
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("SFQ3TbqGOdaaRVfxHtNkn0tvhx0rVj9S"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
@@ -51,19 +56,73 @@ describe("secret-storage", () => {
|
||||
return { secretStorageKey2: {} };
|
||||
}
|
||||
},
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("secretStorageKey"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return true when there is shared secret storage key between master, user signing and self signing keys", async () => {
|
||||
it("should return true when master, user signing and self signing keys are all encrypted with default key", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockReturnValue({ secretStorageKey: {} }),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("secretStorageKey"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false when master, user signing and self signing keys are all encrypted with a non-default key", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockResolvedValue({ defaultKey: {} }),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("anotherCommonKey"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Check canAccessSecrets", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn((secretName) => {
|
||||
if (secretName == "secretA") {
|
||||
return { aaaa: {} };
|
||||
} else if (secretName == "secretB") {
|
||||
return { bbbb: {} };
|
||||
} else if (secretName == "secretC") {
|
||||
return { cccc: {} };
|
||||
} else if (secretName == "secretD") {
|
||||
return { aaaa: {} };
|
||||
} else if (secretName == "secretE") {
|
||||
return { aaaa: {}, bbbb: {} };
|
||||
} else {
|
||||
null;
|
||||
}
|
||||
}),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("aaaa"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretE"])).toStrictEqual(true);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA"])).toStrictEqual(true);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretC"])).toStrictEqual(false);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD"])).toStrictEqual(true);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretC"])).toStrictEqual(false);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretC", "secretA"])).toStrictEqual(false);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "secretB"])).toStrictEqual(
|
||||
false,
|
||||
);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "Unknown"])).toStrictEqual(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretD", "secretE"])).toStrictEqual(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await secretStorageCanAccessSecrets(secretStorage, ["secretA", "secretC", "secretD", "secretE"]),
|
||||
).toStrictEqual(false);
|
||||
expect(await secretStorageCanAccessSecrets(secretStorage, [])).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,9 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import "jest-localstorage-mock";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
|
||||
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { defer } from "../../../src/utils";
|
||||
@@ -76,6 +77,62 @@ describe("IndexedDBStore", () => {
|
||||
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
const setupDefer = defer<Event>();
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
let db: IDBDatabase;
|
||||
req.onupgradeneeded = () => {
|
||||
db = req.result;
|
||||
db.createObjectStore("users", { keyPath: ["userId"] });
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = setupDefer.resolve;
|
||||
await setupDefer.promise;
|
||||
|
||||
// 2. Fill in user presence data
|
||||
const writeDefer = defer<Event>();
|
||||
const transaction = db!.transaction(["users"], "readwrite");
|
||||
const objectStore = transaction.objectStore("users");
|
||||
const request = objectStore.put({
|
||||
userId: "@alice:matrix.org",
|
||||
event: {
|
||||
content: {
|
||||
presence: "online",
|
||||
},
|
||||
sender: "@alice:matrix.org",
|
||||
type: "m.presence",
|
||||
},
|
||||
});
|
||||
request.onsuccess = writeDefer.resolve;
|
||||
await writeDefer.promise;
|
||||
|
||||
// 3. Close database
|
||||
req.result.close();
|
||||
|
||||
// 2. Check if the code loads presence events
|
||||
const store = new IndexedDBStore({
|
||||
indexedDB: indexedDB,
|
||||
dbName: "db3",
|
||||
localStorage,
|
||||
});
|
||||
let userCreated = false;
|
||||
let presenceEventEmitted = false;
|
||||
store.setUserCreator((id: string) => {
|
||||
userCreated = true;
|
||||
const user = new User(id);
|
||||
user.on(UserEvent.Presence, () => {
|
||||
presenceEventEmitted = true;
|
||||
});
|
||||
return user;
|
||||
});
|
||||
await store.startup();
|
||||
expect(userCreated).toBe(true);
|
||||
expect(presenceEventEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it("should use MemoryStore methods for pending events if no localStorage", async () => {
|
||||
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
|
||||
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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", () => {
|
||||
|
||||
+2
-4
@@ -243,9 +243,7 @@ export interface LoginResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
|
||||
* `m.login.token` issuance request.
|
||||
* Note that this is UNSTABLE and subject to breaking changes without notice.
|
||||
* The result of a successful `m.login.token` issuance request as per https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv1loginget_token
|
||||
*/
|
||||
export interface LoginTokenPostResponse {
|
||||
/**
|
||||
@@ -255,7 +253,7 @@ export interface LoginTokenPostResponse {
|
||||
/**
|
||||
* Expiration in seconds.
|
||||
*
|
||||
* @deprecated this is only provided for compatibility with original revision of the MSC.
|
||||
* @deprecated this is only provided for compatibility with original revision of [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882).
|
||||
*/
|
||||
expires_in: number;
|
||||
/**
|
||||
|
||||
+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;
|
||||
|
||||
@@ -41,7 +41,7 @@ export enum AutoDiscoveryAction {
|
||||
FAIL_ERROR = "FAIL_ERROR",
|
||||
}
|
||||
|
||||
enum AutoDiscoveryError {
|
||||
export enum AutoDiscoveryError {
|
||||
Invalid = "Invalid homeserver discovery response",
|
||||
GenericFailure = "Failed to get autodiscovery configuration from server",
|
||||
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
|
||||
@@ -114,7 +114,7 @@ export class AutoDiscovery {
|
||||
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
|
||||
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
|
||||
|
||||
/**
|
||||
* The auto discovery failed. The client is expected to communicate
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base64 encoding and decoding utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
// A brief note on the state of base64 encoding in Javascript.
|
||||
// As of 2023, there is still no common native impl between both browsers and
|
||||
// node. Older Webpack provides an impl for Buffer and there is a polyfill class
|
||||
// for it. There are also plenty of pure js impls, eg. base64-js which has 2336
|
||||
// dependents at current count. Using this would probably be fine although it's
|
||||
// a little under-docced and run by an individual. The node impl works fine,
|
||||
// the browser impl works but predates Uint8Array and so only uses strings.
|
||||
// Right now, switching between native (or polyfilled) impls like this feels
|
||||
// like the least bad option, but... *shrugs*.
|
||||
if (typeof Buffer === "function") {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
} else if (typeof btoa === "function" && uint8Array instanceof Uint8Array) {
|
||||
// ArrayBuffer is a node concept so the param should always be a Uint8Array on
|
||||
// the browser. We need to check because ArrayBuffers don't have reduce.
|
||||
return btoa(uint8Array.reduce((acc, current) => acc + String.fromCharCode(current), ""));
|
||||
} else {
|
||||
throw new Error("No base64 impl found!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
// See encodeBase64 for a short treatise on base64 en/decoding in JS
|
||||
if (typeof Buffer === "function") {
|
||||
return Buffer.from(base64, "base64");
|
||||
} else if (typeof atob === "function") {
|
||||
const itFunc = function* (): Generator<number> {
|
||||
const decoded = atob(
|
||||
// built-in atob doesn't support base64url: convert so we support either
|
||||
base64.replace("-", "+").replace("_", "/"),
|
||||
);
|
||||
for (let i = 0; i < decoded.length; ++i) {
|
||||
yield decoded.charCodeAt(i);
|
||||
}
|
||||
};
|
||||
return Uint8Array.from(itFunc());
|
||||
} else {
|
||||
throw new Error("No base64 impl found!");
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,15 @@ declare global {
|
||||
/* eslint-enable no-var */
|
||||
}
|
||||
|
||||
if (global.__js_sdk_entrypoint) {
|
||||
if (globalThis.__js_sdk_entrypoint) {
|
||||
throw new Error("Multiple matrix-js-sdk entrypoints detected!");
|
||||
}
|
||||
global.__js_sdk_entrypoint = true;
|
||||
globalThis.__js_sdk_entrypoint = true;
|
||||
|
||||
// just *accessing* indexedDB throws an exception in firefox with indexeddb disabled.
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = global.indexedDB;
|
||||
indexedDB = globalThis.indexedDB;
|
||||
} catch (e) {}
|
||||
|
||||
// if our browser (appears to) support indexeddb, use an indexeddb crypto store.
|
||||
@@ -40,8 +40,5 @@ if (indexedDB) {
|
||||
matrixcs.setCryptoStoreFactory(() => new matrixcs.IndexedDBCryptoStore(indexedDB!, "matrix-js-sdk:crypto"));
|
||||
}
|
||||
|
||||
// We export 3 things to make browserify happy as well as downstream projects.
|
||||
// It's awkward, but required.
|
||||
export * from "./matrix";
|
||||
export default matrixcs; // keep export for browserify package deps
|
||||
global.matrixcs = matrixcs;
|
||||
globalThis.matrixcs = matrixcs;
|
||||
|
||||
+351
-160
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,13 @@ 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
|
||||
@@ -34,8 +35,8 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
|
||||
* no room-level equivalent for this setting.
|
||||
*
|
||||
* @remarks this is here, rather than in `CryptoApi`, because I don't think we're
|
||||
* going to support it in the rust crypto implementation.
|
||||
* @remarks This has no effect in Rust Crypto; it exists only for the sake of
|
||||
* the accessors in MatrixClient.
|
||||
*/
|
||||
globalErrorOnUnknownDevices: boolean;
|
||||
|
||||
@@ -47,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;
|
||||
|
||||
@@ -71,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
|
||||
@@ -88,6 +89,7 @@ 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;
|
||||
|
||||
@@ -99,6 +101,13 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @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
|
||||
@@ -181,3 +190,67 @@ export interface OnSyncCompletedData {
|
||||
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;
|
||||
}
|
||||
|
||||
+169
-8
@@ -22,6 +22,7 @@ import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescri
|
||||
import { VerificationRequest } from "./crypto-api/verification";
|
||||
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
|
||||
@@ -39,14 +40,11 @@ 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
|
||||
* Return the current version of the crypto module.
|
||||
* For example: `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`.
|
||||
* @returns the formatted version
|
||||
*/
|
||||
userHasCrossSigningKeys(): Promise<boolean>;
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
@@ -88,6 +86,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.
|
||||
*
|
||||
@@ -126,6 +140,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.
|
||||
*
|
||||
@@ -164,6 +186,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>;
|
||||
|
||||
@@ -228,7 +252,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>;
|
||||
|
||||
@@ -246,6 +273,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
|
||||
@@ -342,11 +379,25 @@ export interface CryptoApi {
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
*
|
||||
* @deprecated prefer the variant with a `version` parameter.
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
|
||||
|
||||
/**
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
* It is the responsability of the caller to check that the decryption key is valid for the given backup version.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
* @param version - the backup version corresponding to this decryption key
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current status of key backup.
|
||||
*
|
||||
@@ -371,6 +422,24 @@ export interface CryptoApi {
|
||||
* 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,6 +456,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).
|
||||
@@ -588,5 +697,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;
|
||||
@@ -68,3 +69,21 @@ 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;
|
||||
}
|
||||
|
||||
@@ -224,13 +224,25 @@ export enum VerificationPhase {
|
||||
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
|
||||
Ready,
|
||||
|
||||
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
|
||||
/**
|
||||
* The verification is in flight.
|
||||
*
|
||||
* This means that an `m.key.verification.start` event has been sent or received, choosing a verification method;
|
||||
* however the verification has not yet completed or been cancelled.
|
||||
*/
|
||||
Started,
|
||||
|
||||
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
|
||||
/**
|
||||
* An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling
|
||||
* the verification request
|
||||
*/
|
||||
Cancelled,
|
||||
|
||||
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
|
||||
/**
|
||||
* The verification request is complete.
|
||||
*
|
||||
* Normally this means that `m.key.verification.done` events have been sent and received.
|
||||
*/
|
||||
Done,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { PkSigning } from "@matrix-org/olm";
|
||||
import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
|
||||
import { IObject, pkSign, pkVerify } from "./olmlib";
|
||||
import { logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
@@ -30,7 +30,11 @@ 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";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
|
||||
// backwards-compatibility re-exports
|
||||
export { UserTrustLevel };
|
||||
|
||||
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
|
||||
|
||||
@@ -587,46 +591,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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm";
|
||||
|
||||
import { logger, PrefixedLogger } from "../logger";
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
|
||||
@@ -531,7 +531,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[createOutboundSession]"),
|
||||
logger.getChild("[createOutboundSession]"),
|
||||
);
|
||||
return newSessionId!;
|
||||
}
|
||||
@@ -588,7 +588,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[createInboundSession]"),
|
||||
logger.getChild("[createInboundSession]"),
|
||||
);
|
||||
|
||||
return result!;
|
||||
@@ -602,7 +602,7 @@ export class OlmDevice {
|
||||
* @returns a list of known session ids for the device
|
||||
*/
|
||||
public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> {
|
||||
const log = logger.withPrefix("[getSessionIdsForDevice]");
|
||||
const log = logger.getChild("[getSessionIdsForDevice]");
|
||||
|
||||
if (theirDeviceIdentityKey in this.sessionsInProgress) {
|
||||
log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`);
|
||||
@@ -642,7 +642,7 @@ export class OlmDevice {
|
||||
public async getSessionIdForDevice(
|
||||
theirDeviceIdentityKey: string,
|
||||
nowait = false,
|
||||
log?: PrefixedLogger,
|
||||
log?: Logger,
|
||||
): Promise<string | null> {
|
||||
const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log);
|
||||
|
||||
@@ -686,9 +686,9 @@ export class OlmDevice {
|
||||
public async getSessionInfoForDevice(
|
||||
deviceIdentityKey: string,
|
||||
nowait = false,
|
||||
log = logger,
|
||||
log: Logger = logger,
|
||||
): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> {
|
||||
log = log.withPrefix("[getSessionInfoForDevice]");
|
||||
log = log.getChild("[getSessionInfoForDevice]");
|
||||
|
||||
if (deviceIdentityKey in this.sessionsInProgress && !nowait) {
|
||||
log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`);
|
||||
@@ -764,7 +764,7 @@ export class OlmDevice {
|
||||
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[encryptMessage]"),
|
||||
logger.getChild("[encryptMessage]"),
|
||||
);
|
||||
return res!;
|
||||
}
|
||||
@@ -806,7 +806,7 @@ export class OlmDevice {
|
||||
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[decryptMessage]"),
|
||||
logger.getChild("[decryptMessage]"),
|
||||
);
|
||||
return payloadString!;
|
||||
}
|
||||
@@ -842,7 +842,7 @@ export class OlmDevice {
|
||||
matches = sessionInfo.session.matches_inbound(ciphertext);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[matchesSession]"),
|
||||
logger.getChild("[matchesSession]"),
|
||||
);
|
||||
return matches!;
|
||||
}
|
||||
@@ -1142,7 +1142,7 @@ export class OlmDevice {
|
||||
},
|
||||
);
|
||||
},
|
||||
logger.withPrefix("[addInboundGroupSession]"),
|
||||
logger.getChild("[addInboundGroupSession]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1282,7 +1282,7 @@ export class OlmDevice {
|
||||
};
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[decryptGroupMessage]"),
|
||||
logger.getChild("[decryptGroupMessage]"),
|
||||
);
|
||||
|
||||
if (error!) {
|
||||
@@ -1328,7 +1328,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[hasInboundSessionKeys]"),
|
||||
logger.getChild("[hasInboundSessionKeys]"),
|
||||
);
|
||||
|
||||
return result!;
|
||||
@@ -1398,7 +1398,7 @@ export class OlmDevice {
|
||||
};
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[getInboundGroupSessionKey]"),
|
||||
logger.getChild("[getInboundGroupSessionKey]"),
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -1443,7 +1443,7 @@ export class OlmDevice {
|
||||
(txn) => {
|
||||
result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn);
|
||||
},
|
||||
logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"),
|
||||
logger.getChild("[getSharedHistoryInboundGroupSessionsForRoom]"),
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { decodeBase64, encodeBase64 } from "./olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
import { subtleCrypto, crypto, TextEncoder } from "./crypto";
|
||||
|
||||
// salt for HKDF, with 8 bytes of zeros
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import { logger, Logger } from "../../logger";
|
||||
import * as olmlib from "../olmlib";
|
||||
import {
|
||||
DecryptionAlgorithm,
|
||||
@@ -246,12 +246,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
};
|
||||
|
||||
protected readonly roomId: string;
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
public constructor(params: IParams & Required<Pick<IParams, "roomId">>) {
|
||||
super(params);
|
||||
this.roomId = params.roomId;
|
||||
this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`);
|
||||
this.prefixedLogger = logger.getChild(`[${this.roomId} encryption]`);
|
||||
|
||||
this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100;
|
||||
this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000;
|
||||
@@ -333,7 +333,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
|
||||
// need to make a brand new session?
|
||||
if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
|
||||
this.prefixedLogger.log("Starting new megolm session because we need to rotate.");
|
||||
this.prefixedLogger.debug("Starting new megolm session because we need to rotate.");
|
||||
session = null;
|
||||
}
|
||||
|
||||
@@ -343,9 +343,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
this.prefixedLogger.log("Starting new megolm session");
|
||||
this.prefixedLogger.debug("Starting new megolm session");
|
||||
session = await this.prepareNewSession(sharedHistory);
|
||||
this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`);
|
||||
this.prefixedLogger.debug(`Started new megolm session ${session.sessionId}`);
|
||||
this.outboundSessions[session.sessionId] = session;
|
||||
}
|
||||
|
||||
@@ -968,12 +968,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
for (let i = 0; i < userDeviceMaps.length; i++) {
|
||||
try {
|
||||
await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload);
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`Completed blacklist notification for ${session.sessionId} ` +
|
||||
`(slice ${i + 1}/${userDeviceMaps.length})`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`blacklist notification for ${session.sessionId} ` +
|
||||
`(slice ${i + 1}/${userDeviceMaps.length}) failed`,
|
||||
);
|
||||
@@ -1054,7 +1054,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
* @returns Promise which resolves to the new event body
|
||||
*/
|
||||
public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> {
|
||||
this.prefixedLogger.log("Starting to encrypt event");
|
||||
this.prefixedLogger.debug("Starting to encrypt event");
|
||||
|
||||
if (this.encryptionPreparation != null) {
|
||||
// If we started sending keys, wait for it to be done.
|
||||
@@ -1291,12 +1291,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
private olmlib = olmlib;
|
||||
|
||||
protected readonly roomId: string;
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) {
|
||||
super(params);
|
||||
this.roomId = params.roomId;
|
||||
this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`);
|
||||
this.prefixedLogger = logger.getChild(`[${this.roomId} decryption]`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1740,7 +1740,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
"readwrite",
|
||||
["parked_shared_history"],
|
||||
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn),
|
||||
logger.withPrefix("[addParkedSharedHistory]"),
|
||||
logger.getChild("[addParkedSharedHistory]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1955,7 +1955,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
"sharing keys for session " +
|
||||
body.sender_key +
|
||||
"|" +
|
||||
@@ -2051,7 +2051,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => {
|
||||
// This throws if the upload failed, but this is fine
|
||||
// since it will have written it to the db and will retry.
|
||||
this.prefixedLogger.log("Failed to back up megolm session", e);
|
||||
this.prefixedLogger.debug("Failed to back up megolm session", e);
|
||||
});
|
||||
}
|
||||
// have another go at decrypting events sent with this session.
|
||||
@@ -2135,7 +2135,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
|
||||
|
||||
const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`Sharing history in with users ${Array.from(devicesByUser.keys())}`,
|
||||
sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
|
||||
);
|
||||
@@ -2178,20 +2178,20 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
for (const [userId, deviceMessages] of contentMap) {
|
||||
for (const [deviceId, content] of deviceMessages) {
|
||||
if (!hasCiphertext(content)) {
|
||||
this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
|
||||
this.prefixedLogger.debug("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
|
||||
deviceMessages.delete(deviceId);
|
||||
}
|
||||
}
|
||||
// No devices left for that user? Strip that too.
|
||||
if (deviceMessages.size === 0) {
|
||||
this.prefixedLogger.log("Pruned all devices for user " + userId);
|
||||
this.prefixedLogger.debug("Pruned all devices for user " + userId);
|
||||
contentMap.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Is there anything left?
|
||||
if (contentMap.size === 0) {
|
||||
this.prefixedLogger.log("No users left to send to: aborting");
|
||||
this.prefixedLogger.debug("No users left to send to: aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+60
-4
@@ -25,7 +25,7 @@ import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { DeviceTrustLevel } from "./CrossSigning";
|
||||
import { keyFromPassphrase } from "./key_passphrase";
|
||||
import { safeSet, sleep } from "../utils";
|
||||
import { encodeUri, safeSet, sleep } from "../utils";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { encodeRecoveryKey } from "./recoverykey";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
||||
@@ -39,8 +39,9 @@ import {
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { CryptoEvent } from "./index";
|
||||
import { crypto } from "./crypto";
|
||||
import { HTTPError, MatrixError } from "../http-api";
|
||||
import { ClientPrefix, HTTPError, MatrixError, Method } from "../http-api";
|
||||
import { BackupTrustInfo } from "../crypto-api/keybackup";
|
||||
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
|
||||
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
@@ -224,6 +225,33 @@ export class BackupManager {
|
||||
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all key backups.
|
||||
*
|
||||
* Will call the API to delete active backup until there is no more present.
|
||||
*/
|
||||
public async deleteAllKeyBackupVersions(): Promise<void> {
|
||||
// there could be several backup versions, delete all to be safe.
|
||||
let current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
|
||||
while (current != null) {
|
||||
await this.deleteKeyBackupVersion(current);
|
||||
this.disableKeyBackup();
|
||||
current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given key backup.
|
||||
*
|
||||
* @param version - The backup version to delete.
|
||||
*/
|
||||
public async deleteKeyBackupVersion(version: string): Promise<void> {
|
||||
const path = encodeUri("/room_keys/version/$version", { $version: version });
|
||||
await this.baseApis.http.authedRequest<void>(Method.Delete, path, undefined, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the server for an active key backup and
|
||||
* if one is present and has a valid signature from
|
||||
@@ -333,7 +361,7 @@ export class BackupManager {
|
||||
};
|
||||
|
||||
if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) {
|
||||
logger.info("Key backup is absent or missing required data");
|
||||
logger.info(`Key backup is absent or missing required data: ${JSON.stringify(backupInfo)}`);
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -452,7 +480,6 @@ export class BackupManager {
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay);
|
||||
if (!this.clientRunning) {
|
||||
logger.debug("Key backup send aborted, client stopped");
|
||||
this.sendingBackups = false;
|
||||
return;
|
||||
}
|
||||
@@ -851,3 +878,32 @@ export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): Backup
|
||||
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link BackupDecryptor} for the libolm crypto backend.
|
||||
*/
|
||||
export class LibOlmBackupDecryptor implements BackupDecryptor {
|
||||
private algorithm: BackupAlgorithm;
|
||||
public readonly sourceTrusted: boolean;
|
||||
|
||||
public constructor(algorithm: BackupAlgorithm) {
|
||||
this.algorithm = algorithm;
|
||||
this.sourceTrusted = !algorithm.untrusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#free}
|
||||
*/
|
||||
public free(): void {
|
||||
this.algorithm.free();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#decryptSessions}
|
||||
*/
|
||||
public async decryptSessions(
|
||||
sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>,
|
||||
): Promise<IMegolmSessionData[]> {
|
||||
return await this.algorithm.decryptSessions(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ limitations under the License.
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
export let crypto = global.window?.crypto;
|
||||
export let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
|
||||
export let TextEncoder = global.window?.TextEncoder;
|
||||
export let crypto = globalThis.window?.crypto;
|
||||
export let subtleCrypto = globalThis.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
|
||||
export let TextEncoder = globalThis.window?.TextEncoder;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
if (!crypto) {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto";
|
||||
import { decodeBase64, encodeBase64 } from "./olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
import { logger } from "../logger";
|
||||
|
||||
+171
-17
@@ -50,7 +50,7 @@ import { IllegalMethod } from "./verification/IllegalMethod";
|
||||
import { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
||||
import { DehydrationManager } from "./dehydration";
|
||||
import { BackupManager, backupTrustInfoFromLegacyTrustInfo } from "./backup";
|
||||
import { BackupManager, LibOlmBackupDecryptor, backupTrustInfoFromLegacyTrustInfo } from "./backup";
|
||||
import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
@@ -73,7 +73,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import {
|
||||
@@ -91,6 +91,9 @@ import {
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
@@ -98,6 +101,8 @@ import {
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
import { ClientPrefix, MatrixError, Method } from "../http-api";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type {
|
||||
@@ -130,7 +135,7 @@ export const verificationMethods = {
|
||||
export type VerificationMethod = keyof typeof verificationMethods | string;
|
||||
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return Boolean(global.Olm);
|
||||
return Boolean(globalThis.Olm);
|
||||
}
|
||||
|
||||
// minimum time between attempting to unwedge an Olm session, if we succeeded
|
||||
@@ -496,7 +501,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
}
|
||||
|
||||
return olmlib.decodeBase64(fixedKey || storedKey);
|
||||
return decodeBase64(fixedKey || storedKey);
|
||||
}
|
||||
|
||||
// try to get key from app
|
||||
@@ -605,6 +610,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
this.backupManager.checkAndStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getVersion}.
|
||||
*/
|
||||
public getVersion(): string {
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
return `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to trust a others users signatures of their devices.
|
||||
* If false, devices will only be considered 'verified' if we have
|
||||
@@ -700,9 +713,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async userHasCrossSigningKeys(): Promise<boolean> {
|
||||
await this.downloadKeys([this.userId]);
|
||||
return this.deviceList.getStoredCrossSigningForUser(this.userId) !== null;
|
||||
public async userHasCrossSigningKeys(userId = this.userId): Promise<boolean> {
|
||||
await this.downloadKeys([userId]);
|
||||
return this.deviceList.getStoredCrossSigningForUser(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1046,7 +1059,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
newKeyId = await createSSSS(opts, backupKey);
|
||||
|
||||
// store the backup key in secret storage
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey!), [newKeyId]);
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]);
|
||||
|
||||
// The backup is trusted because the user provided the private key.
|
||||
// Sign the backup with the cross-signing key so the key backup can
|
||||
@@ -1088,9 +1101,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// and want to write to the local secretStorage object
|
||||
{ secureSecretStorage: false },
|
||||
);
|
||||
// write the key ourselves to 4S
|
||||
// write the key to 4S
|
||||
const privateKey = decodeRecoveryKey(info.recovery_key);
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
|
||||
// create keyBackupInfo object to add to builder
|
||||
const data: IKeyBackupInfo = {
|
||||
@@ -1118,7 +1131,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
const keyId = newKeyId || oldKeyId;
|
||||
await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null);
|
||||
}
|
||||
const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey));
|
||||
const decodedBackupKey = new Uint8Array(decodeBase64(fixedBackupKey || sessionBackupKey));
|
||||
builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
|
||||
} else if (this.backupManager.getKeyBackupEnabled()) {
|
||||
// key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
|
||||
@@ -1133,7 +1146,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return;
|
||||
}
|
||||
logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS");
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey));
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey));
|
||||
}
|
||||
|
||||
const operation = builder.buildOperation();
|
||||
@@ -1145,6 +1158,48 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
logger.log("Secure Secret Storage ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#resetKeyBackup}.
|
||||
*/
|
||||
public async resetKeyBackup(): Promise<void> {
|
||||
// Delete existing ones
|
||||
// There is no use case for having several key backup version live server side.
|
||||
// Even if not deleted it would be lost as the key to restore is lost.
|
||||
// There should be only one backup at a time.
|
||||
await this.backupManager.deleteAllKeyBackupVersions();
|
||||
|
||||
const info = await this.backupManager.prepareKeyBackupVersion();
|
||||
|
||||
await this.signObject(info.auth_data);
|
||||
|
||||
// add new key backup
|
||||
const { version } = await this.baseApis.http.authedRequest<{ version: string }>(
|
||||
Method.Post,
|
||||
"/room_keys/version",
|
||||
undefined,
|
||||
info,
|
||||
{
|
||||
prefix: ClientPrefix.V3,
|
||||
},
|
||||
);
|
||||
|
||||
logger.log(`Created backup version ${version}`);
|
||||
|
||||
// write the key to 4S
|
||||
const privateKey = info.privateKey;
|
||||
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
await this.storeSessionBackupPrivateKey(privateKey);
|
||||
|
||||
await this.backupManager.checkAndStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#deleteKeyBackupVersion}.
|
||||
*/
|
||||
public async deleteKeyBackupVersion(version: string): Promise<void> {
|
||||
await this.backupManager.deleteKeyBackupVersion(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
|
||||
*/
|
||||
@@ -1255,13 +1310,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
// make sure we have a Uint8Array, rather than a string
|
||||
if (typeof encodedKey === "string") {
|
||||
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(encodedKey) || encodedKey));
|
||||
key = new Uint8Array(decodeBase64(fixBackupKey(encodedKey) || encodedKey));
|
||||
await this.storeSessionBackupPrivateKey(key);
|
||||
}
|
||||
if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) {
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
const decrypted = await decryptAES(encodedKey, pickleKey, "m.megolm_backup.v1");
|
||||
key = olmlib.decodeBase64(decrypted);
|
||||
key = decodeBase64(decrypted);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
@@ -1271,13 +1326,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param key - the private key
|
||||
* @returns a promise so you can catch failures
|
||||
*/
|
||||
public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
|
||||
public async storeSessionBackupPrivateKey(key: ArrayLike<number>, version?: string): Promise<void> {
|
||||
if (!(key instanceof Uint8Array)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
|
||||
}
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1");
|
||||
const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, "m.megolm_backup.v1");
|
||||
return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey);
|
||||
});
|
||||
@@ -1524,6 +1579,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.crossSigningInfo.checkUserTrust(userCrossSigning);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getUserVerificationStatus}.
|
||||
*/
|
||||
public async getUserVerificationStatus(userId: string): Promise<UserTrustLevel> {
|
||||
return this.checkUserTrust(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given device is trusted.
|
||||
*
|
||||
@@ -1792,6 +1854,28 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// the backup with the new key (if not already signed)?
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => {
|
||||
return privKey;
|
||||
});
|
||||
|
||||
// If the pubkey computed from the private data we've been given
|
||||
// doesn't match the one in the auth_data, the user has entered
|
||||
// a different recovery key / the wrong passphrase.
|
||||
if (!(await algorithm.keyMatches(privKey))) {
|
||||
return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
|
||||
}
|
||||
|
||||
return new LibOlmBackupDecryptor(algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a set of keys as our own, trusted, cross-signing keys.
|
||||
*
|
||||
@@ -2651,6 +2735,76 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return ret as IEncryptedEventInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
|
||||
*/
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
const encryptionInfo = this.getEventEncryptionInfo(event);
|
||||
if (!encryptionInfo.encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderId = event.getSender();
|
||||
if (!senderId || encryptionInfo.mismatchedSender) {
|
||||
// something definitely wrong is going on here
|
||||
|
||||
// previously: E2EState.Warning -> E2ePadlockUnverified -> Red/"Encrypted by an unverified session"
|
||||
return {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
const userTrust = this.checkUserTrust(senderId);
|
||||
if (!userTrust.isCrossSigningVerified()) {
|
||||
// If the message is unauthenticated, then display a grey
|
||||
// shield, otherwise if the user isn't cross-signed then
|
||||
// nothing's needed
|
||||
if (!encryptionInfo.authenticated) {
|
||||
// previously: E2EState.Unauthenticated -> E2ePadlockUnauthenticated -> Grey/"The authenticity of this encrypted message can't be guaranteed on this device."
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
};
|
||||
} else {
|
||||
// previously: E2EState.Normal -> no icon
|
||||
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
|
||||
}
|
||||
}
|
||||
|
||||
const eventSenderTrust =
|
||||
senderId &&
|
||||
encryptionInfo.sender &&
|
||||
(await this.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId));
|
||||
|
||||
if (!eventSenderTrust) {
|
||||
// previously: E2EState.Unknown -> E2ePadlockUnknown -> Grey/"Encrypted by a deleted session"
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!eventSenderTrust.isVerified()) {
|
||||
// previously: E2EState.Warning -> E2ePadlockUnverified -> Red/"Encrypted by an unverified session"
|
||||
return {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!encryptionInfo.authenticated) {
|
||||
// previously: E2EState.Unauthenticated -> E2ePadlockUnauthenticated -> Grey/"The authenticity of this encrypted message can't be guaranteed on this device."
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
};
|
||||
}
|
||||
|
||||
// previously: E2EState.Verified -> no icon
|
||||
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the current outbound group session to be discarded such
|
||||
* that another one will be created next time an event is sent.
|
||||
@@ -4051,7 +4205,7 @@ export function fixBackupKey(key?: string): string | null {
|
||||
return null;
|
||||
}
|
||||
const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x));
|
||||
return olmlib.encodeBase64(fixedKey);
|
||||
return encodeBase64(fixedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-20
@@ -14,31 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IEncryptedPayload } from "./aes";
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IKeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> {
|
||||
first_message_index: number;
|
||||
forwarded_count: number;
|
||||
is_verified: boolean;
|
||||
session_data: T;
|
||||
}
|
||||
|
||||
export interface IKeyBackupRoomSessions {
|
||||
[sessionId: string]: IKeyBackupSession;
|
||||
}
|
||||
|
||||
// Export for backward compatibility
|
||||
export type {
|
||||
Curve25519AuthData as ICurve25519AuthData,
|
||||
Aes256AuthData as IAes256AuthData,
|
||||
KeyBackupInfo as IKeyBackupInfo,
|
||||
Curve25519SessionData,
|
||||
KeyBackupSession as IKeyBackupSession,
|
||||
KeyBackupRoomSessions as IKeyBackupRoomSessions,
|
||||
} from "../crypto-api/keybackup";
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
+3
-30
@@ -24,7 +24,7 @@ import type { PkSigning } from "@matrix-org/olm";
|
||||
import type { IOneTimeKey } from "../@types/crypto";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { logger } from "../logger";
|
||||
import { Logger, logger } from "../logger";
|
||||
import { IClaimOTKsResult, MatrixClient } from "../client";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
@@ -215,7 +215,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
force = false,
|
||||
otkTimeout?: number,
|
||||
failedServers?: string[],
|
||||
log = logger,
|
||||
log: Logger = logger,
|
||||
): Promise<Map<string, Map<string, IOlmSessionResult>>> {
|
||||
const devicesWithoutSession: [string, string][] = [
|
||||
// [userId, deviceId], ...
|
||||
@@ -319,7 +319,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
for (const resolver of resolveSession.values()) {
|
||||
resolver();
|
||||
}
|
||||
log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
|
||||
log.debug(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -537,30 +537,3 @@ export function isOlmEncrypted(event: MatrixEvent): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user