Compare commits
137 Commits
v32.0.0
...
v34.0.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ab6260074d | |||
| 25a7c9e140 | |||
| 78b6b878bd | |||
| 4ccb72c0f2 | |||
| 9f1aebbdcb | |||
| 6a15e8f1a0 | |||
| 238eea0ef5 | |||
| ab6f86536f | |||
| 819fc75202 | |||
| c70aa33367 | |||
| 240b43b652 | |||
| 697d5d31d1 | |||
| b1701ff571 | |||
| c55289ec65 | |||
| 987ec1e62f | |||
| 76b240cf57 | |||
| a4c4e7e275 | |||
| 3f5a994a24 | |||
| d754392410 | |||
| 7ecaa53e34 | |||
| 222e95d33f | |||
| 2ee43cade7 | |||
| 9218f6380c | |||
| 661ba76763 | |||
| 4cb851c51a | |||
| 5a3d24abc2 | |||
| 3eed74f1a6 | |||
| 10e7a2d997 | |||
| 969ecdb6fb | |||
| e8b91f2729 | |||
| f80366ff30 | |||
| 395c3cfcd6 | |||
| f95954c233 | |||
| fa5f2d389a | |||
| 9fc557fc6b | |||
| 6436fbb99f | |||
| 87c2ac3ffa | |||
| 43022d5b2f | |||
| a0fadeb4ec | |||
| a3cea8ce7d | |||
| c88487da07 | |||
| 89875b8e31 | |||
| 9c94393d76 | |||
| 7850294a4b | |||
| 131e81401a | |||
| 5c27e30302 | |||
| 8dfb6de3cc | |||
| 042610310f | |||
| 8535604200 | |||
| 3ee64722c5 | |||
| 9d6210b3f9 | |||
| 909caab74e | |||
| 7c87625157 | |||
| b19817bb73 | |||
| 36196ea422 | |||
| a81adf542e | |||
| a49bc3ddf4 | |||
| a86d4ceb49 | |||
| 8c3be2a56a | |||
| 38898a60c7 | |||
| fd3a4d4403 | |||
| 93d96281fd | |||
| 944dc51c58 | |||
| c6b43dd176 | |||
| f03dd7b7bc | |||
| 51fa1866a9 | |||
| d76fb2baa0 | |||
| 3feafc9c17 | |||
| c9075b3dba | |||
| 69c474dda7 | |||
| 73ce51065f | |||
| 5d0407d0a6 | |||
| 3a4b02d8e6 | |||
| d421e7f829 | |||
| 9fd051af33 | |||
| b78a1ad889 | |||
| a25cdcecaa | |||
| 2a716bd076 | |||
| ef1db8d664 | |||
| 22865fd834 | |||
| c4fe564855 | |||
| 9ecb1a0381 | |||
| ef9490c7b1 | |||
| 402adfbe8a | |||
| 41e8c2af34 | |||
| 4843b40296 | |||
| bc2c870152 | |||
| 7c7b2817d3 | |||
| 9f78202ecd | |||
| abc9911e95 | |||
| efdae0d66f | |||
| 2bf554761c | |||
| c2687643b5 | |||
| a33758eda6 | |||
| 8faed02cc5 | |||
| 3ae0dab47a | |||
| 95394e4cbe | |||
| 8c9bbc01fc | |||
| eb888791a3 | |||
| 6c0b2f55e1 | |||
| c9a5eaece3 | |||
| 64505de36b | |||
| 65d858f9a3 | |||
| 1da5e8f56a | |||
| 5efd4c2915 | |||
| bc03950f8a | |||
| c09da9a23f | |||
| e874468ba3 | |||
| 6fedda91f9 | |||
| d22a39f5d7 | |||
| 4fc6ba884e | |||
| c30e498013 | |||
| 8240bf0ae7 | |||
| 2321c44687 | |||
| 0137e9d5a8 | |||
| 28bbc51752 | |||
| 0db3ac9b43 | |||
| 53039b78ee | |||
| 2a06d19431 | |||
| a747eef04c | |||
| 583823c2ef | |||
| 26d13c15c3 | |||
| c850ca3179 | |||
| 8438533532 | |||
| 475f82c5ce | |||
| 936e7c3072 | |||
| 82ed7bd86a | |||
| cb67eae858 | |||
| 5cdd524da7 | |||
| 0ff0093380 | |||
| b352405c89 | |||
| 0d73d0c6c7 | |||
| d2f76d4956 | |||
| c680dd7eb2 | |||
| e24bb0f50c | |||
| 1ed3b13f0d | |||
| 4f628bf64c |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"sourceMaps": true,
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"node": 10
|
||||
},
|
||||
"modules": "commonjs"
|
||||
}
|
||||
],
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-numeric-separator",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-object-rest-spread",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
}
|
||||
@@ -49,6 +49,22 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
"no-restricted-properties": [
|
||||
"error",
|
||||
{
|
||||
object: "window",
|
||||
property: "setImmediate",
|
||||
message: "Use setTimeout instead.",
|
||||
},
|
||||
],
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
name: "setImmediate",
|
||||
message: "Use setTimeout instead.",
|
||||
},
|
||||
],
|
||||
|
||||
"import/no-restricted-paths": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Build downstream artifacts
|
||||
on:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
pull_request: {}
|
||||
|
||||
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
#push:
|
||||
# branches: [develop, master]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.95.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
@@ -3,10 +3,16 @@
|
||||
|
||||
name: matrix-react-sdk End to End Tests
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build downstream artifacts"]
|
||||
types:
|
||||
- completed
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
pull_request: {}
|
||||
|
||||
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
#push:
|
||||
# branches: [develop, master]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
@@ -15,44 +21,14 @@ concurrency:
|
||||
jobs:
|
||||
playwright:
|
||||
name: Playwright
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume multiple concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
deployments: write
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
# We want to make the Playwright 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 Playwright 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@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: success
|
||||
description: Playwright skipped
|
||||
|
||||
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
|
||||
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
|
||||
context: "${{ github.workflow }} / end-to-end-tests"
|
||||
|
||||
sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume multiple concurrent runners.
|
||||
skip: ${{ github.event_name != 'merge_group' }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- repo: vector-im/element-web
|
||||
- repo: element-hq/element-web
|
||||
event: element-web-notify
|
||||
- repo: matrix-org/matrix-react-sdk
|
||||
event: upstream-sdk-notify
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@80a96a4863886addcbc9f681b5b295ba7f5424e1 # v5
|
||||
- uses: mheap/github-action-required-labels@5847eef68201219cf0a4643ea7be61e77837bbce # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Release Drafter
|
||||
# Workflow used by other workflows to generate draft releases.
|
||||
name: Release Drafter Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Generates the draft release for the js-sdk
|
||||
# Normally triggered whenever anything is merged to the staging branch, but
|
||||
# also has a workflow dispatch trigger in case it needs running manually due
|
||||
# to failures / workflow updates etc.
|
||||
name: Release Drafter
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Get draft release
|
||||
id: draft-release
|
||||
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
|
||||
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@4b07b26a2f6e0a51846e1870223e545bae91c552 # v3.0.1
|
||||
uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c # v3.1.1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
|
||||
@@ -12,10 +12,6 @@ on:
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
|
||||
extra_args:
|
||||
type: string
|
||||
required: false
|
||||
description: "Extra args to pass to SonarCloud"
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,7 +30,7 @@ jobs:
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
@@ -60,14 +56,14 @@ jobs:
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
coverage=$(find coverage -type f -name '*lcov.info' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
|
||||
echo "sonar.javascript.lcov.reportPaths=$coverage" >> sonar-project.properties
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
|
||||
echo "sonar.testExecutionReportPaths=$reports" >> sonar-project.properties
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.7
|
||||
uses: matrix-org/sonarcloud-workflow-action@v3.2
|
||||
# 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:
|
||||
@@ -78,10 +74,6 @@ jobs:
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
extra_args: |
|
||||
${{ inputs.extra_args }}
|
||||
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
|
||||
-Dsonar.testExecutionReportPaths=${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
if: always()
|
||||
|
||||
@@ -90,3 +90,19 @@ jobs:
|
||||
path: _docs
|
||||
# We'll only use this in a workflow_run, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
analyse_dead_code:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Run linter
|
||||
run: "yarn run lint:knip"
|
||||
|
||||
@@ -71,6 +71,24 @@ jobs:
|
||||
disable_coverage: true
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
|
||||
complement-crypto:
|
||||
name: "Run Complement Crypto tests"
|
||||
if: github.event_name == 'merge_group'
|
||||
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
|
||||
with:
|
||||
use_js_sdk: "."
|
||||
|
||||
# we need this so the job is reported properly when run in a merge queue
|
||||
downstream-complement-crypto:
|
||||
name: Downstream Complement Crypto tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs:
|
||||
- complement-crypto
|
||||
steps:
|
||||
- if: needs.complement-crypto.result != 'skipped' && needs.complement-crypto.result != 'success'
|
||||
run: exit 1
|
||||
|
||||
# Hook for branch protection to skip downstream testing outside of merge queues
|
||||
# and skip sonarcloud coverage within merge queues
|
||||
downstream:
|
||||
|
||||
@@ -6,6 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
call-triage-labelled:
|
||||
uses: vector-im/element-web/.github/workflows/triage-labelled.yml@develop
|
||||
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@develop
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,88 @@
|
||||
Changes in [33.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.1.0) (2024-06-18)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MSC4108 support OIDC QR code login ([#4134](https://github.com/matrix-org/matrix-js-sdk/pull/4134)). Contributed by @t3chguy.
|
||||
* Add crypto methods for export and import of secrets bundle ([#4227](https://github.com/matrix-org/matrix-js-sdk/pull/4227)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix screen sharing in recent Chrome ([#4243](https://github.com/matrix-org/matrix-js-sdk/pull/4243)). Contributed by @RiotRobot.
|
||||
* Fix incorrect assumptions about required fields in /search response ([#4228](https://github.com/matrix-org/matrix-js-sdk/pull/4228)). Contributed by @t3chguy.
|
||||
* Fix the queueToDevice tests for the new fakeindexeddb ([#4225](https://github.com/matrix-org/matrix-js-sdk/pull/4225)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [33.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.0.0) (2024-06-04)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* Remove more deprecated methods, fields, and exports ([#4217](https://github.com/matrix-org/matrix-js-sdk/pull/4217)). Contributed by @t3chguy.
|
||||
* Remove deprecated methods and fields ([#4201](https://github.com/matrix-org/matrix-js-sdk/pull/4201)). Contributed by @t3chguy.
|
||||
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Remove more deprecated methods, fields, and exports ([#4217](https://github.com/matrix-org/matrix-js-sdk/pull/4217)). Contributed by @t3chguy.
|
||||
* Remove deprecated methods and fields ([#4201](https://github.com/matrix-org/matrix-js-sdk/pull/4201)). Contributed by @t3chguy.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* `initRustCrypto`: allow app to pass in the store key directly ([#4210](https://github.com/matrix-org/matrix-js-sdk/pull/4210)). Contributed by @richvdh.
|
||||
* Preserve ESM for async imports to work correctly ([#4187](https://github.com/matrix-org/matrix-js-sdk/pull/4187)). Contributed by @ms-dosx86.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Don't run migration for Rust crypto if the legacy store is empty ([#4218](https://github.com/matrix-org/matrix-js-sdk/pull/4218)). Contributed by @andybalaam.
|
||||
* Bump matrix-sdk-crypto-wasm to 5.0.0 ([#4216](https://github.com/matrix-org/matrix-js-sdk/pull/4216)). Contributed by @richvdh.
|
||||
* Wire up verification cancel \& mismatch for rust crypto ([#4202](https://github.com/matrix-org/matrix-js-sdk/pull/4202)). Contributed by @t3chguy.
|
||||
* Only pass id\_server if we had one to begin with ([#4200](https://github.com/matrix-org/matrix-js-sdk/pull/4200)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [32.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.4.0) (2024-05-22)
|
||||
==================================================================================================
|
||||
* No changes
|
||||
|
||||
|
||||
Changes in [32.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.3.0) (2024-05-21)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Simplify OIDC types \& export `decodeIdToken` ([#4193](https://github.com/matrix-org/matrix-js-sdk/pull/4193)). Contributed by @t3chguy.
|
||||
* Add helpers for authenticated media, and associated documentation ([#4185](https://github.com/matrix-org/matrix-js-sdk/pull/4185)). Contributed by @turt2live.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix state\_events.ts types ([#4196](https://github.com/matrix-org/matrix-js-sdk/pull/4196)). Contributed by @t3chguy.
|
||||
* Fix sendEventHttpRequest for `m.room.redaction` events without `redacts` ([#4192](https://github.com/matrix-org/matrix-js-sdk/pull/4192)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [32.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.2.0) (2024-05-07)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Use a different error code for UTDs when user was not in the room ([#4172](https://github.com/matrix-org/matrix-js-sdk/pull/4172)). Contributed by @uhoreg.
|
||||
* Modernize window.crypto access constants ([#4169](https://github.com/matrix-org/matrix-js-sdk/pull/4169)). Contributed by @turt2live.
|
||||
* Improve compliance with MSC3266 ([#4155](https://github.com/matrix-org/matrix-js-sdk/pull/4155)). Contributed by @AndrewFerr.
|
||||
* Add comment to make clear that RoomStateEvent.Events does not update related objects in the js-sdk ([#4152](https://github.com/matrix-org/matrix-js-sdk/pull/4152)). Contributed by @toger5.
|
||||
* Crypto: use a new error code for UTDs from device-relative historical events ([#4139](https://github.com/matrix-org/matrix-js-sdk/pull/4139)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Element-R: Fix rust migration when ssss secret are stored not encryted in cache (old legacy behavior) ([#4168](https://github.com/matrix-org/matrix-js-sdk/pull/4168)). Contributed by @BillCarsonFr.
|
||||
|
||||
|
||||
Changes in [32.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.1.0) (2024-04-23)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Add support for device dehydration v2 (Element R) ([#4062](https://github.com/matrix-org/matrix-js-sdk/pull/4062)). Contributed by @uhoreg.
|
||||
* OIDC improvements in prep of OIDC-QR reciprocation ([#4149](https://github.com/matrix-org/matrix-js-sdk/pull/4149)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Validate backup private key before migrating it ([#4114](https://github.com/matrix-org/matrix-js-sdk/pull/4114)). Contributed by @BillCarsonFr.
|
||||
* ElementR| Retry query backup until it works during migration to avoid spurious correption error popup ([#4113](https://github.com/matrix-org/matrix-js-sdk/pull/4113)). Contributed by @BillCarsonFr.
|
||||
|
||||
|
||||
Changes in [32.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.0.0) (2024-04-09)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
@@ -21,6 +21,10 @@ endpoints from before Matrix 1.1, for example.
|
||||
|
||||
# Quickstart
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
|
||||
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
|
||||
|
||||
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
|
||||
if you do not have it already.
|
||||
|
||||
@@ -89,6 +93,34 @@ Object.keys(client.store.rooms).forEach((roomId) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Authenticated media
|
||||
|
||||
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) (Matrix 1.11) will require clients, like
|
||||
yours, to include an `Authorization` header when `/download`ing or `/thumbnail`ing media. For NodeJS environments this
|
||||
may be as easy as the following code snippet, though web browsers may need to use [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
||||
to append the header when using the endpoints in `<img />` elements and similar.
|
||||
|
||||
```javascript
|
||||
const downloadUrl = client.mxcUrlToHttp(
|
||||
/*mxcUrl=*/ "mxc://example.org/abc123", // the MXC URI to download/thumbnail, typically from an event or profile
|
||||
/*width=*/ undefined, // part of the thumbnail API. Use as required.
|
||||
/*height=*/ undefined, // part of the thumbnail API. Use as required.
|
||||
/*resizeMethod=*/ undefined, // part of the thumbnail API. Use as required.
|
||||
/*allowDirectLinks=*/ false, // should generally be left `false`.
|
||||
/*allowRedirects=*/ true, // implied supported with authentication
|
||||
/*useAuthentication=*/ true, // the flag we're after in this example
|
||||
);
|
||||
const img = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.getAccessToken()}`,
|
||||
},
|
||||
});
|
||||
// Do something with `img`.
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> In future the js-sdk will _only_ return authentication-required URLs, mandating population of the `Authorization` header.
|
||||
|
||||
## What does this SDK do?
|
||||
|
||||
This SDK provides a full object model around the Matrix Client-Server API and emits
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
sourceMaps: true,
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
targets: {
|
||||
esmodules: true,
|
||||
},
|
||||
// We want to output ES modules for the final build (mostly to ensure that
|
||||
// async imports work correctly). However, jest doesn't support ES modules very
|
||||
// well yet (see https://github.com/jestjs/jest/issues/9430), so we use commonjs
|
||||
// when testing.
|
||||
modules: process.env.NODE_ENV === "test" ? "commonjs" : false,
|
||||
},
|
||||
],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-transform-numeric-separator",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-object-rest-spread",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-runtime",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
"src/types.ts",
|
||||
"src/browser-index.ts",
|
||||
"src/indexeddb-worker.ts",
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
"release.sh",
|
||||
// For now, we include all source files as entrypoints as we have been bad about gutwrenched imports
|
||||
"src/**",
|
||||
],
|
||||
project: ["**/*.{js,ts}"],
|
||||
ignore: ["examples/**"],
|
||||
ignoreDependencies: [
|
||||
// Required for `action-validator`
|
||||
"@action-validator/*",
|
||||
// Used for git pre-commit hooks
|
||||
"husky",
|
||||
// Used in script which only runs in environment with `@octokit/rest` installed
|
||||
"@octokit/rest",
|
||||
// Used by jest
|
||||
"jest-environment-jsdom",
|
||||
"babel-jest",
|
||||
"ts-node",
|
||||
// Used by `@babel/plugin-transform-runtime`
|
||||
"@babel/runtime",
|
||||
],
|
||||
ignoreBinaries: [
|
||||
// Used when available by reusable workflow `.github/workflows/release-make.yml`
|
||||
"dist",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
} satisfies KnipConfig;
|
||||
+13
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "32.0.0",
|
||||
"version": "34.0.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -19,6 +19,7 @@
|
||||
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||
"lint:knip": "knip",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"coverage": "yarn test --coverage"
|
||||
@@ -52,7 +53,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^6.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -64,7 +65,7 @@
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "9"
|
||||
"uuid": "10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@action-validator/cli": "^0.6.0",
|
||||
@@ -73,21 +74,19 @@
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/eslint-parser": "^7.12.10",
|
||||
"@babel/eslint-plugin": "^7.12.10",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.1",
|
||||
"@babel/plugin-proposal-numeric-separator": "^7.12.7",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-class-properties": "^7.12.1",
|
||||
"@babel/plugin-transform-numeric-separator": "^7.12.7",
|
||||
"@babel/plugin-transform-object-rest-spread": "^7.12.1",
|
||||
"@babel/plugin-transform-runtime": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/domexception": "^4.0.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
@@ -96,17 +95,16 @@
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^48.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.3.0",
|
||||
"eslint-plugin-unicorn": "^53.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -115,12 +113,13 @@
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"knip": "^5.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"prettier": "3.3.2",
|
||||
"rimraf": "^5.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.25.10",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
|
||||
@@ -347,6 +347,67 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
|
||||
expect(isCrossSigningReady).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false if identity is not trusted, even if the secrets are in 4S", async () => {
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
|
||||
// Complete initial sync, to get the 4S account_data events stored
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
|
||||
// For this test we need to have a well-formed 4S setup.
|
||||
const mockSecretInfo = {
|
||||
encrypted: {
|
||||
// Don't care about the actual values here, just need to be present for validation
|
||||
KeyId: {
|
||||
iv: "IVIVIVIVIVIVIV",
|
||||
ciphertext: "CIPHERTEXTB64",
|
||||
mac: "MACMACMAC",
|
||||
},
|
||||
},
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.secret_storage.key.KeyId",
|
||||
content: {
|
||||
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
|
||||
// iv and mac not relevant for this test
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.secret_storage.default_key",
|
||||
content: {
|
||||
key: "KeyId",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.master",
|
||||
content: mockSecretInfo,
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.user_signing",
|
||||
content: mockSecretInfo,
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.self_signing",
|
||||
content: mockSecretInfo,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Sanity: ensure that the secrets are in 4S
|
||||
const status = await aliceClient.getCrypto()!.getCrossSigningStatus();
|
||||
expect(status.privateKeysInSecretStorage).toBeTruthy();
|
||||
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningKeyId", () => {
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
} from "./olm-utils";
|
||||
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
|
||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,6 +109,8 @@ afterEach(() => {
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -467,6 +470,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
|
||||
describe("Unable to decrypt error codes", function () {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
it("Decryption fails with UISI error", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
@@ -474,11 +481,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
|
||||
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
|
||||
|
||||
// Ensure that the timestamp post-dates the creation of our device
|
||||
const encryptedEvent = {
|
||||
...testData.ENCRYPTED_EVENT,
|
||||
origin_server_ts: Date.now(),
|
||||
};
|
||||
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -498,12 +511,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
|
||||
await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]);
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
// Ensure that the timestamp post-dates the creation of our device
|
||||
const encryptedEvent = {
|
||||
...testData.ENCRYPTED_EVENT,
|
||||
origin_server_ts: Date.now(),
|
||||
};
|
||||
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -515,6 +533,219 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
|
||||
});
|
||||
|
||||
describe("Historical events", () => {
|
||||
async function sendEventAndAwaitDecryption(props: Partial<IEvent> = {}): Promise<MatrixEvent> {
|
||||
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
|
||||
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
|
||||
|
||||
// Ensure that the timestamp pre-dates the creation of our device: set it to 24 hours ago
|
||||
const encryptedEvent = {
|
||||
...testData.ENCRYPTED_EVENT,
|
||||
origin_server_ts: Date.now() - 24 * 3600 * 1000,
|
||||
...props,
|
||||
};
|
||||
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
return await awaitDecryption;
|
||||
}
|
||||
|
||||
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption();
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
});
|
||||
|
||||
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption();
|
||||
expect(ev.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
);
|
||||
});
|
||||
|
||||
newBackendOnly("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => {
|
||||
// The test backup data is signed by a dummy device. We'll need to tell Alice about the device, and
|
||||
// later, tell her to trust it, so that she trusts the backup.
|
||||
const e2eResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
|
||||
e2eResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
await aliceClient
|
||||
.getCrypto()!
|
||||
.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// Tell Alice to trust the dummy device that signed the backup
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
// Tell Alice to check and enable backup
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
|
||||
// Sanity: Alice should now have working backup.
|
||||
expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual(
|
||||
testData.SIGNED_BACKUP_DATA.version,
|
||||
);
|
||||
|
||||
// Finally! we can check what happens when we get an event.
|
||||
const ev = await sendEventAndAwaitDecryption();
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
|
||||
});
|
||||
|
||||
newBackendOnly("fails with NOT_JOINED if user is not member of room", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]: "leave",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
// This tests that when the server reports that the user
|
||||
// was invited at the time the event was sent, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
|
||||
// and instead get some other error, since the user should
|
||||
// have gotten the key for the event.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]: "invite",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports that the user
|
||||
// was invited at the time the event was sent, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
|
||||
// and instead get some other error, since the user should
|
||||
// have gotten the key for the event.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
// This tests that when the server reports the user's
|
||||
// membership, and reports that the user was joined, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
|
||||
// instead get some other error.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]: "join",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports the user's
|
||||
// membership, and reports that the user was joined, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
|
||||
// instead get some other error.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("Decryption fails with Unable to decrypt for other errors", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
@@ -997,10 +1228,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
return encryptedMessage;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
newBackendOnly("should rotate the session after 2 messages", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
@@ -2184,10 +2411,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (url: string, options: RequestInit) => {
|
||||
@@ -2250,10 +2473,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
|
||||
describe("getUserDeviceInfo", () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
|
||||
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
|
||||
const queryResponseBody = {
|
||||
@@ -2742,10 +2961,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Should be able to restore from 4S after bootstrap", async () => {
|
||||
const backupVersion = "1";
|
||||
await bootstrapSecurity(backupVersion);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
|
||||
describe("Device dehydration", () => {
|
||||
it("should rehydrate and dehydrate a device", async () => {
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
cryptoCallbacks: {
|
||||
getSecretStorageKey: async (keys: any, name: string) => {
|
||||
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
|
||||
|
||||
// count the number of times the dehydration key gets set
|
||||
let setDehydrationCount = 0;
|
||||
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
|
||||
if (event.getType() === "org.matrix.msc3814") {
|
||||
setDehydrationCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
|
||||
// start dehydration -- we start with no dehydrated device, and we
|
||||
// store the dehydrated device that we create
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
let dehydratedDeviceBody: any;
|
||||
let dehydrationCount = 0;
|
||||
let resolveDehydrationPromise: () => void;
|
||||
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
|
||||
dehydratedDeviceBody = JSON.parse(opts.body as string);
|
||||
dehydrationCount++;
|
||||
if (resolveDehydrationPromise) {
|
||||
resolveDehydrationPromise();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await crypto.startDehydration();
|
||||
|
||||
expect(dehydrationCount).toEqual(1);
|
||||
|
||||
// a week later, we should have created another dehydrated device
|
||||
const dehydrationPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveDehydrationPromise = resolve;
|
||||
});
|
||||
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
|
||||
await dehydrationPromise;
|
||||
expect(dehydrationCount).toEqual(2);
|
||||
|
||||
// restart dehydration -- rehydrate the device that we created above,
|
||||
// and create a new dehydrated device. We also set `createNewKey`, so
|
||||
// a new dehydration key will be set
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
device_id: dehydratedDeviceBody.device_id,
|
||||
device_data: dehydratedDeviceBody.device_data,
|
||||
});
|
||||
const eventsResponse = jest.fn((url, opts) => {
|
||||
// rehydrating should make two calls to the /events endpoint.
|
||||
// The first time will return a single event, and the second
|
||||
// time will return no events (which will signal to the
|
||||
// rehydration function that it can stop)
|
||||
const body = JSON.parse(opts.body as string);
|
||||
const nextBatch = body.next_batch ?? "0";
|
||||
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
|
||||
return {
|
||||
events,
|
||||
next_batch: nextBatch + "1",
|
||||
};
|
||||
});
|
||||
fetchMock.post(
|
||||
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
|
||||
eventsResponse,
|
||||
);
|
||||
await crypto.startDehydration(true);
|
||||
expect(dehydrationCount).toEqual(3);
|
||||
|
||||
expect(setDehydrationCount).toEqual(2);
|
||||
expect(eventsResponse.mock.calls).toHaveLength(2);
|
||||
|
||||
matrixClient.stopClient();
|
||||
});
|
||||
});
|
||||
|
||||
/** create a new secret storage and cross-signing keys */
|
||||
async function initializeSecretStorage(
|
||||
matrixClient: MatrixClient,
|
||||
userId: string,
|
||||
homeserverUrl: string,
|
||||
): Promise<void> {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
|
||||
const accountData: Map<string, object> = new Map();
|
||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
const value = accountData.get(name);
|
||||
if (value) {
|
||||
return value;
|
||||
} else {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
const value = JSON.parse(opts.body as string);
|
||||
accountData.set(name, value);
|
||||
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
|
||||
return {};
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
const crypto = matrixClient.getCrypto()! as RustCrypto;
|
||||
// we need to process a sync so that the OlmMachine will upload keys
|
||||
await crypto.preprocessToDeviceMessages([]);
|
||||
await crypto.onSyncCompleted({});
|
||||
|
||||
// create initial secret storage
|
||||
async function createSecretStorageKey() {
|
||||
return {
|
||||
keyInfo: {} as AddSecretStorageKeyOpts,
|
||||
privateKey: new Uint8Array(32),
|
||||
};
|
||||
}
|
||||
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
await matrixClient.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
setupNewSecretStorage: true,
|
||||
setupNewKeyBackup: false,
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { Mocked } from "jest-mock";
|
||||
|
||||
import {
|
||||
createClient,
|
||||
CryptoApi,
|
||||
Crypto,
|
||||
CryptoEvent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
@@ -45,6 +45,7 @@ import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keyback
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, IDeferred } from "../../../src/utils";
|
||||
import { DecryptionFailureCode } from "../../../src/crypto-api";
|
||||
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
@@ -242,8 +243,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
// On the first decryption attempt, decryption fails.
|
||||
await awaitDecryption(event);
|
||||
expect(event.decryptionFailureReason).toEqual(
|
||||
backend === "libolm"
|
||||
? DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID
|
||||
: DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP,
|
||||
);
|
||||
|
||||
// Eventually, decryption succeeds.
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
@@ -300,7 +310,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
let aliceCrypto: CryptoApi;
|
||||
let aliceCrypto: Crypto.CryptoApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
@@ -786,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
jest.runAllTimers();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000);
|
||||
await failurePromise;
|
||||
|
||||
// Fix the endpoint to do successful uploads
|
||||
@@ -819,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
|
||||
// run the timers, which will make the backup loop redo the request
|
||||
await jest.runAllTimersAsync();
|
||||
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
|
||||
await successPromise;
|
||||
await allKeysUploadedPromise;
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump"
|
||||
import { MSK_NOT_CACHED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump";
|
||||
import { IDENTITY_NOT_TRUSTED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/unverified";
|
||||
import { FULL_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/full_account";
|
||||
import { EMPTY_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/empty_account";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
@@ -85,6 +86,44 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should create the meta db if given a storageKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ storageKey: new Uint8Array(32) });
|
||||
|
||||
// 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"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should create the meta db if given a storagePassword", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ storagePassword: "the cow is on the moon" });
|
||||
|
||||
// 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"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore a second call", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
@@ -162,6 +201,142 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
|
||||
}, 60000);
|
||||
|
||||
describe("Private key backup migration", () => {
|
||||
it("should not migrate the backup private key if backup has changed", async () => {
|
||||
// Here we have a new backup server side, and the migrated account has the previous backup key.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.newBackupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should not migrate the backup private key if backup has unknown algorithm", async () => {
|
||||
// Here we have a new backup server side, and the migrated account has the previous backup key.
|
||||
const backupResponse = {
|
||||
...MSK_NOT_CACHED_DATASET.backupResponse,
|
||||
algorithm: "m.megolm_backup.v8",
|
||||
};
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should not migrate the backup private key if the backup has been deleted", async () => {
|
||||
// The old backup has been deleted server side.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No backup found",
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should migrate the backup private key if the backup matches", async () => {
|
||||
// The old backup has been deleted server side.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.backupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not migrate if account data is missing", async () => {
|
||||
// See https://github.com/element-hq/element-web/issues/27447
|
||||
|
||||
// Given we have an almost-empty legacy account in the database
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No backup found" },
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", EMPTY_ACCOUNT_DATASET.keyQueryResponse);
|
||||
|
||||
const testStoreName = "test-store";
|
||||
await populateStore(testStoreName, EMPTY_ACCOUNT_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, testStoreName);
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: EMPTY_ACCOUNT_DATASET.userId,
|
||||
deviceId: EMPTY_ACCOUNT_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: EMPTY_ACCOUNT_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
// When we start Rust crypto, potentially triggering an upgrade
|
||||
const progressListener = jest.fn();
|
||||
matrixClient.addListener(CryptoEvent.LegacyCryptoStoreMigrationProgress, progressListener);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// Then no error occurs, and no upgrade happens
|
||||
expect(progressListener.mock.calls.length).toBe(0);
|
||||
}, 60000);
|
||||
|
||||
describe("Legacy trust migration", () => {
|
||||
async function populateAndStartLegacyCryptoStore(dumpPath: string): Promise<IndexedDBCryptoStore> {
|
||||
const testStoreName = "test-store";
|
||||
|
||||
@@ -19,7 +19,16 @@ import { Mocked } from "jest-mock";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
import { Filter, KnockRoomOpts, MemoryStore, Method, Room, SERVICE_TYPES } from "../../src/matrix";
|
||||
import {
|
||||
Filter,
|
||||
JoinRule,
|
||||
KnockRoomOpts,
|
||||
MemoryStore,
|
||||
Method,
|
||||
Room,
|
||||
RoomSummary,
|
||||
SERVICE_TYPES,
|
||||
} from "../../src/matrix";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { IFilterDefinition } from "../../src/filter";
|
||||
@@ -248,7 +257,7 @@ describe("MatrixClient", function () {
|
||||
.when("POST", "/knock/" + encodeURIComponent(roomId))
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ reason: opts.reason });
|
||||
expect(request.queryParams).toEqual({ server_name: opts.viaServers });
|
||||
expect(request.queryParams).toEqual({ server_name: opts.viaServers, via: opts.viaServers });
|
||||
})
|
||||
.respond(200, { room_id: roomId });
|
||||
|
||||
@@ -1284,18 +1293,109 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
describe("getCapabilities", () => {
|
||||
it("should cache by default", async () => {
|
||||
it("should return cached capabilities if present", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: {
|
||||
"m.change_password": false,
|
||||
},
|
||||
capabilities: capsObject,
|
||||
});
|
||||
const prom = httpBackend.flushAllExpected();
|
||||
const capabilities1 = await client.getCapabilities();
|
||||
const capabilities2 = await client.getCapabilities();
|
||||
|
||||
client.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(await client.getCapabilities()).toEqual(capsObject);
|
||||
});
|
||||
|
||||
it("should fetch capabilities if cache not present", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
|
||||
const capsPromise = client.getCapabilities();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(await capsPromise).toEqual(capsObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedCapabilities", () => {
|
||||
it("should return cached capabilities or undefined", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
|
||||
expect(client.getCachedCapabilities()).toBeUndefined();
|
||||
|
||||
client.startClient();
|
||||
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(client.getCachedCapabilities()).toEqual(capsObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCapabilities", () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should always fetch capabilities and then cache", async () => {
|
||||
const prom = client.fetchCapabilities();
|
||||
await httpBackend.flushAllExpected();
|
||||
const caps = await prom;
|
||||
|
||||
expect(caps).toEqual(capsObject);
|
||||
});
|
||||
|
||||
it("should write-through the cache", async () => {
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
|
||||
client.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(client.getCachedCapabilities()).toEqual(capsObject);
|
||||
|
||||
const newCapsObject = {
|
||||
"m.change_password": true,
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: newCapsObject,
|
||||
});
|
||||
|
||||
const prom = client.fetchCapabilities();
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
|
||||
expect(capabilities1).toStrictEqual(capabilities2);
|
||||
expect(client.getCachedCapabilities()).toEqual(newCapsObject);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1710,6 +1810,102 @@ describe("MatrixClient", function () {
|
||||
await Promise.all([client.unbindThreePid("email", "alice@server.com"), httpBackend.flushAllExpected()]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoomSummary", () => {
|
||||
const roomId = "!foo:bar";
|
||||
const encodedRoomId = encodeURIComponent(roomId);
|
||||
|
||||
const roomSummary: RoomSummary = {
|
||||
"room_id": roomId,
|
||||
"name": "My Room",
|
||||
"avatar_url": "",
|
||||
"topic": "My room topic",
|
||||
"world_readable": false,
|
||||
"guest_can_join": false,
|
||||
"num_joined_members": 1,
|
||||
"room_type": "",
|
||||
"join_rule": JoinRule.Public,
|
||||
"membership": "leave",
|
||||
"im.nheko.summary.room_version": "6",
|
||||
"im.nheko.summary.encryption": "algo",
|
||||
};
|
||||
|
||||
const prefix = "/_matrix/client/unstable/im.nheko.summary/";
|
||||
const suffix = `summary/${encodedRoomId}`;
|
||||
const deprecatedSuffix = `rooms/${encodedRoomId}/summary`;
|
||||
|
||||
const errorUnrecogStatus = 404;
|
||||
const errorUnrecogBody = {
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
error: "Unsupported endpoint",
|
||||
};
|
||||
|
||||
const errorBadreqStatus = 400;
|
||||
const errorBadreqBody = {
|
||||
errcode: "M_UNKNOWN",
|
||||
error: "Invalid request",
|
||||
};
|
||||
|
||||
it("should respond with a valid room summary object", () => {
|
||||
httpBackend.when("GET", prefix + suffix).respond(200, roomSummary);
|
||||
|
||||
const prom = client.getRoomSummary(roomId).then((response) => {
|
||||
expect(response).toEqual(roomSummary);
|
||||
});
|
||||
|
||||
httpBackend.flush("");
|
||||
return prom;
|
||||
});
|
||||
|
||||
it("should allow fallback to the deprecated endpoint", () => {
|
||||
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
|
||||
httpBackend.when("GET", prefix + deprecatedSuffix).respond(200, roomSummary);
|
||||
|
||||
const prom = client.getRoomSummary(roomId).then((response) => {
|
||||
expect(response).toEqual(roomSummary);
|
||||
});
|
||||
|
||||
httpBackend.flush("");
|
||||
return prom;
|
||||
});
|
||||
|
||||
it("should respond to unsupported path with error", () => {
|
||||
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
|
||||
httpBackend.when("GET", prefix + deprecatedSuffix).respond(errorUnrecogStatus, errorUnrecogBody);
|
||||
|
||||
const prom = client.getRoomSummary(roomId).then(
|
||||
function (response) {
|
||||
throw Error("request not failed");
|
||||
},
|
||||
function (error) {
|
||||
expect(error.httpStatus).toEqual(errorUnrecogStatus);
|
||||
expect(error.errcode).toEqual(errorUnrecogBody.errcode);
|
||||
expect(error.message).toEqual(`MatrixError: [${errorUnrecogStatus}] ${errorUnrecogBody.error}`);
|
||||
},
|
||||
);
|
||||
|
||||
httpBackend.flush("");
|
||||
return prom;
|
||||
});
|
||||
|
||||
it("should respond to invalid path arguments with error", () => {
|
||||
httpBackend.when("GET", prefix).respond(errorBadreqStatus, errorBadreqBody);
|
||||
|
||||
const prom = client.getRoomSummary("notAroom").then(
|
||||
function (response) {
|
||||
throw Error("request not failed");
|
||||
},
|
||||
function (error) {
|
||||
expect(error.httpStatus).toEqual(errorBadreqStatus);
|
||||
expect(error.errcode).toEqual(errorBadreqBody.errcode);
|
||||
expect(error.message).toEqual(`MatrixError: [${errorBadreqStatus}] ${errorBadreqBody.error}`);
|
||||
},
|
||||
);
|
||||
|
||||
httpBackend.flush("");
|
||||
return prom;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
@@ -1720,7 +1916,6 @@ function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
const buildEventMessageInThread = (root: MatrixEvent) =>
|
||||
new MatrixEvent({
|
||||
age: 80098509,
|
||||
content: {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"ciphertext": "ENCRYPTEDSTUFF",
|
||||
@@ -1741,12 +1936,10 @@ const buildEventMessageInThread = (root: MatrixEvent) =>
|
||||
sender: "@andybalaam-test1:matrix.org",
|
||||
type: "m.room.encrypted",
|
||||
unsigned: { age: 80098509 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventPollResponseReference = () =>
|
||||
new MatrixEvent({
|
||||
age: 80098509,
|
||||
content: {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"ciphertext": "ENCRYPTEDSTUFF",
|
||||
@@ -1764,7 +1957,6 @@ const buildEventPollResponseReference = () =>
|
||||
sender: "@andybalaam-test1:matrix.org",
|
||||
type: "m.room.encrypted",
|
||||
unsigned: { age: 80106237 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventReaction = (event: MatrixEvent) =>
|
||||
@@ -1804,7 +1996,6 @@ const buildEventRedaction = (event: MatrixEvent) =>
|
||||
|
||||
const buildEventPollStartThreadRoot = () =>
|
||||
new MatrixEvent({
|
||||
age: 80108647,
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext: "ENCRYPTEDSTUFF",
|
||||
@@ -1818,12 +2009,10 @@ const buildEventPollStartThreadRoot = () =>
|
||||
sender: "@andybalaam-test1:matrix.org",
|
||||
type: "m.room.encrypted",
|
||||
unsigned: { age: 80108647 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventReply = (target: MatrixEvent) =>
|
||||
new MatrixEvent({
|
||||
age: 80098509,
|
||||
content: {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"ciphertext": "ENCRYPTEDSTUFF",
|
||||
@@ -1842,12 +2031,10 @@ const buildEventReply = (target: MatrixEvent) =>
|
||||
sender: "@andybalaam-test1:matrix.org",
|
||||
type: "m.room.encrypted",
|
||||
unsigned: { age: 80098509 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventRoomName = () =>
|
||||
new MatrixEvent({
|
||||
age: 80123249,
|
||||
content: {
|
||||
name: "1 poll, 1 vote, 1 thread",
|
||||
},
|
||||
@@ -1858,12 +2045,10 @@ const buildEventRoomName = () =>
|
||||
state_key: "",
|
||||
type: "m.room.name",
|
||||
unsigned: { age: 80123249 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventEncryption = () =>
|
||||
new MatrixEvent({
|
||||
age: 80123383,
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
@@ -1874,12 +2059,10 @@ const buildEventEncryption = () =>
|
||||
state_key: "",
|
||||
type: "m.room.encryption",
|
||||
unsigned: { age: 80123383 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventGuestAccess = () =>
|
||||
new MatrixEvent({
|
||||
age: 80123473,
|
||||
content: {
|
||||
guest_access: "can_join",
|
||||
},
|
||||
@@ -1890,12 +2073,10 @@ const buildEventGuestAccess = () =>
|
||||
state_key: "",
|
||||
type: "m.room.guest_access",
|
||||
unsigned: { age: 80123473 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventHistoryVisibility = () =>
|
||||
new MatrixEvent({
|
||||
age: 80123556,
|
||||
content: {
|
||||
history_visibility: "shared",
|
||||
},
|
||||
@@ -1906,12 +2087,10 @@ const buildEventHistoryVisibility = () =>
|
||||
state_key: "",
|
||||
type: "m.room.history_visibility",
|
||||
unsigned: { age: 80123556 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventJoinRules = () =>
|
||||
new MatrixEvent({
|
||||
age: 80123696,
|
||||
content: {
|
||||
join_rule: KnownMembership.Invite,
|
||||
},
|
||||
@@ -1922,12 +2101,10 @@ const buildEventJoinRules = () =>
|
||||
state_key: "",
|
||||
type: "m.room.join_rules",
|
||||
unsigned: { age: 80123696 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventPowerLevels = () =>
|
||||
new MatrixEvent({
|
||||
age: 80124105,
|
||||
content: {
|
||||
ban: 50,
|
||||
events: {
|
||||
@@ -1958,12 +2135,10 @@ const buildEventPowerLevels = () =>
|
||||
state_key: "",
|
||||
type: "m.room.power_levels",
|
||||
unsigned: { age: 80124105 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventMember = () =>
|
||||
new MatrixEvent({
|
||||
age: 80125279,
|
||||
content: {
|
||||
avatar_url: "mxc://matrix.org/aNtbVcFfwotudypZcHsIcPOc",
|
||||
displayname: "andybalaam-test1",
|
||||
@@ -1976,12 +2151,10 @@ const buildEventMember = () =>
|
||||
state_key: "@andybalaam-test1:matrix.org",
|
||||
type: "m.room.member",
|
||||
unsigned: { age: 80125279 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
const buildEventCreate = () =>
|
||||
new MatrixEvent({
|
||||
age: 80126105,
|
||||
content: {
|
||||
room_version: "6",
|
||||
},
|
||||
@@ -1992,7 +2165,6 @@ const buildEventCreate = () =>
|
||||
state_key: "",
|
||||
type: "m.room.create",
|
||||
unsigned: { age: 80126105 },
|
||||
user_id: "@andybalaam-test1:matrix.org",
|
||||
});
|
||||
|
||||
function assertObjectContains(obj: Record<string, any>, expected: any): void {
|
||||
|
||||
@@ -333,7 +333,7 @@ describe("MatrixClient room timelines", function () {
|
||||
name: userName,
|
||||
url: "mxc://some/url",
|
||||
});
|
||||
oldMshipEvent.prev_content = {
|
||||
oldMshipEvent.unsigned!.prev_content = {
|
||||
displayname: "Old Alice",
|
||||
avatar_url: undefined,
|
||||
membership: KnownMembership.Join,
|
||||
|
||||
@@ -105,13 +105,13 @@ describe("MatrixClient syncing errors", () => {
|
||||
|
||||
await client!.startClient();
|
||||
expect(await syncEvents[0].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
jest.advanceTimersByTime(60 * 1000); // 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
|
||||
jest.advanceTimersByTime(60 * 1000); // 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
|
||||
jest.advanceTimersByTime(60 * 1000); // 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
|
||||
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("MatrixClient syncing errors", () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.get("end:capabilities", {})
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
Copyright 2024 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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import {
|
||||
MSC4108FailureReason,
|
||||
MSC4108RendezvousSession,
|
||||
MSC4108SecureChannel,
|
||||
MSC4108SignInWithQR,
|
||||
PayloadType,
|
||||
RendezvousError,
|
||||
} from "../../../src/rendezvous";
|
||||
import { defer } from "../../../src/utils";
|
||||
import {
|
||||
ClientPrefix,
|
||||
DEVICE_CODE_SCOPE,
|
||||
IHttpOpts,
|
||||
IMyDevice,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
MatrixHttpApi,
|
||||
} from "../../../src";
|
||||
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
|
||||
const baseUrl = "https://example.com";
|
||||
const crypto = {
|
||||
exportSecretsForQrLogin: jest.fn(),
|
||||
};
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature(feature: string) {
|
||||
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
baseUrl,
|
||||
getDomain: () => "example.com",
|
||||
getDevice: jest.fn(),
|
||||
getCrypto: jest.fn(() => crypto),
|
||||
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
|
||||
} as unknown as MatrixClient;
|
||||
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
|
||||
baseUrl: client.baseUrl,
|
||||
prefix: ClientPrefix.Unstable,
|
||||
onlyData: true,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("MSC4108SignInWithQR", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.get(
|
||||
"https://issuer/.well-known/openid-configuration",
|
||||
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
|
||||
);
|
||||
fetchMock.get("https://issuer/jwks", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
const url = "https://fallbackserver/rz/123";
|
||||
const deviceId = "DEADB33F";
|
||||
const verificationUri = "https://example.com/verify";
|
||||
const verificationUriComplete = "https://example.com/verify/complete";
|
||||
|
||||
it("should generate qr code data as expected", async () => {
|
||||
const session = new MSC4108RendezvousSession({
|
||||
url,
|
||||
});
|
||||
const channel = new MSC4108SecureChannel(session);
|
||||
const login = new MSC4108SignInWithQR(channel, false);
|
||||
|
||||
await login.generateCode();
|
||||
const code = login.code;
|
||||
expect(code).toHaveLength(71);
|
||||
const text = new TextDecoder().decode(code);
|
||||
expect(text.startsWith("MATRIX")).toBeTruthy();
|
||||
expect(text.endsWith(url)).toBeTruthy();
|
||||
|
||||
// Assert that the code is stable
|
||||
await login.generateCode();
|
||||
expect(login.code).toEqual(code);
|
||||
});
|
||||
|
||||
describe("should be able to connect as a reciprocating device", () => {
|
||||
let client: MatrixClient;
|
||||
let ourLogin: MSC4108SignInWithQR;
|
||||
let opponentLogin: MSC4108SignInWithQR;
|
||||
|
||||
beforeEach(async () => {
|
||||
let ourData = defer<string>();
|
||||
let opponentData = defer<string>();
|
||||
|
||||
const ourMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
ourData.resolve(newData);
|
||||
}),
|
||||
receive: jest.fn(() => {
|
||||
const prom = opponentData.promise;
|
||||
prom.then(() => {
|
||||
opponentData = defer();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
url,
|
||||
cancelled: false,
|
||||
cancel: () => {
|
||||
// @ts-ignore
|
||||
ourMockSession.cancelled = true;
|
||||
ourData.resolve("");
|
||||
},
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const opponentMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
opponentData.resolve(newData);
|
||||
}),
|
||||
receive: jest.fn(() => {
|
||||
const prom = ourData.promise;
|
||||
prom.then(() => {
|
||||
ourData = defer();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
|
||||
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
|
||||
|
||||
const ourChannel = new MSC4108SecureChannel(ourMockSession);
|
||||
const qrCodeData = QrCodeData.fromBytes(
|
||||
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
|
||||
);
|
||||
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
|
||||
|
||||
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
|
||||
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share server name & check code", async () => {
|
||||
await Promise.all([
|
||||
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
|
||||
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ serverName: client.getDomain() }),
|
||||
]);
|
||||
|
||||
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share verificationUri", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
|
||||
verificationUri: verificationUriComplete,
|
||||
}),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
verification_uri_complete: verificationUriComplete,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort if device already exists", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort on unsupported protocol", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
|
||||
"Received a request for an unsupported protocol",
|
||||
),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant_v2",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share secrets", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
|
||||
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
};
|
||||
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
|
||||
|
||||
const payload = {
|
||||
secrets: expect.objectContaining(secrets),
|
||||
};
|
||||
await Promise.all([
|
||||
expect(ourProm).resolves.toEqual(payload),
|
||||
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort if device doesn't come up by timeout", async () => {
|
||||
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
|
||||
(<Function>fn)();
|
||||
return -1;
|
||||
});
|
||||
jest.spyOn(Date, "now").mockImplementation(() => {
|
||||
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
|
||||
});
|
||||
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
// @ts-ignore
|
||||
await opponentLogin.send({
|
||||
type: PayloadType.Success,
|
||||
});
|
||||
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
await expect(ourProm).rejects.toThrow("New device not found");
|
||||
});
|
||||
|
||||
it("should abort on unexpected errors", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
// @ts-ignore
|
||||
await opponentLogin.send({
|
||||
type: PayloadType.Success,
|
||||
});
|
||||
mocked(client.getDevice).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
|
||||
);
|
||||
|
||||
await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
|
||||
});
|
||||
|
||||
it("should abort on declined login", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
await ourLogin.declineLoginOnExistingDevice();
|
||||
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
|
||||
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not send secrets if user cancels", async () => {
|
||||
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
|
||||
(<Function>fn)();
|
||||
return -1;
|
||||
});
|
||||
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
const opponentProm = opponentLogin.shareSecrets();
|
||||
|
||||
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
const deferred = defer<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deferred.promise);
|
||||
|
||||
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
|
||||
deferred.resolve({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
};
|
||||
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourProm).rejects.toThrow("User cancelled"),
|
||||
expect(opponentProm).rejects.toThrow("Unexpected message received"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DOMException from "domexception";
|
||||
|
||||
global.DOMException = DOMException as typeof global.DOMException;
|
||||
|
||||
jest.mock("../src/http-api/utils", () => ({
|
||||
...jest.requireActual("../src/http-api/utils"),
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
|
||||
@@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
|
||||
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
|
||||
getIdentityServerUrl: jest.fn(),
|
||||
getHomeserverUrl: jest.fn(),
|
||||
getCapabilities: jest.fn().mockReturnValue({}),
|
||||
getCachedCapabilities: jest.fn().mockReturnValue({}),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
|
||||
@@ -38,14 +38,18 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns ValidatedIssuerMetadata
|
||||
*/
|
||||
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
||||
export const mockOpenIdConfiguration = (
|
||||
issuer = "https://auth.org/",
|
||||
additionalGrantTypes: string[] = [],
|
||||
): ValidatedIssuerMetadata => ({
|
||||
issuer,
|
||||
revocation_endpoint: issuer + "revoke",
|
||||
token_endpoint: issuer + "token",
|
||||
authorization_endpoint: issuer + "auth",
|
||||
registration_endpoint: issuer + "registration",
|
||||
device_authorization_endpoint: issuer + "device",
|
||||
jwks_uri: issuer + "jwks",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
|
||||
@@ -173,8 +173,10 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC
|
||||
room_id: opts.room,
|
||||
sender: opts.sender || opts.user, // opts.user for backwards-compat
|
||||
content: opts.content,
|
||||
prev_content: opts.prev_content,
|
||||
unsigned: opts.unsigned || {},
|
||||
unsigned: {
|
||||
...opts.unsigned,
|
||||
prev_content: opts.prev_content,
|
||||
},
|
||||
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
|
||||
txn_id: "~" + Math.random(),
|
||||
redacts: opts.redacts,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## Dump of an empty libolm indexeddb cryptostore to test skipping migration
|
||||
|
||||
A dump of an account which is almost completely empty, and totally unsuitable
|
||||
for use as a real account.
|
||||
|
||||
This dump was manually created by copying and editing full_account.
|
||||
|
||||
Created to test
|
||||
["Unable to restore session" error due due to half-initialised legacy indexeddb crypto store #27447](https://github.com/element-hq/element-web/issues/27447).
|
||||
We should not launch the Rust migration code when we find a DB in this state.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"account": [],
|
||||
"device_data": [],
|
||||
"inbound_group_sessions": [],
|
||||
"inbound_group_sessions_withheld": [],
|
||||
"notified_error_devices": [],
|
||||
"outgoingRoomKeyRequests": [],
|
||||
"parked_shared_history": [],
|
||||
"rooms": [],
|
||||
"session_problems": [],
|
||||
"sessions": [],
|
||||
"sessions_needing_backup": [],
|
||||
"shared_history_inbound_group_sessions": []
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 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 { DumpDataSetInfo } from "../index";
|
||||
|
||||
/**
|
||||
* A key query response containing the current keys of the tested user.
|
||||
* To be used during tests with fetchmock.
|
||||
*/
|
||||
const KEYS_QUERY_RESPONSE = { device_keys: { "@emptyuser:example.com": {} } };
|
||||
|
||||
/**
|
||||
* A dataset containing the information for the tested user.
|
||||
* To be used during tests.
|
||||
*/
|
||||
export const EMPTY_ACCOUNT_DATASET: DumpDataSetInfo = {
|
||||
userId: "@emptyuser:example.com",
|
||||
deviceId: "EMPTYDEVIC",
|
||||
pickleKey: "+/bcdefghijklmnopqrstu1/zyxvutsrqponmlkjih2",
|
||||
keyQueryResponse: KEYS_QUERY_RESPONSE,
|
||||
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/empty_account/dump.json",
|
||||
};
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2024 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 { DumpDataSetInfo } from "../index";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2024 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 { KeyBackupInfo } from "../../../../src/crypto-api/keybackup";
|
||||
import { DumpDataSetInfo } from "../index";
|
||||
|
||||
@@ -245,6 +261,28 @@ const BACKUP_RESPONSE: KeyBackupInfo = {
|
||||
count: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* This was generated by doing a backup reset on the account.
|
||||
* This is a new valid backup for this account.
|
||||
*/
|
||||
const NEW_BACKUP_RESPONSE: KeyBackupInfo = {
|
||||
auth_data: {
|
||||
public_key: "CkDxWALi3lcChgjEZFEM6clYq5x768XBwsL++eaOzTI",
|
||||
signatures: {
|
||||
"@migration:localhost": {
|
||||
"ed25519:YVEGEYPYWX":
|
||||
"ZSYuQDdwgB9WKXQ+z5aWWfqSolBCGRw53kur1Vy956gFefgzCBkMbw5M0I2UgfU2Cukri7jZ4ig201zmLNmaAA",
|
||||
"ed25519:rXCrBin/+xyh+yW//vWte+2UV0et1ZHTWfalp/Ekack":
|
||||
"+UQ8EA507LoIqgK9rPsqPoGrj+iRBJeY2Oz0mMtXmVf8c1y8G0KWJNUWqvOysnOhsoJf1bt8ey48CxjjtSQ2AA",
|
||||
},
|
||||
},
|
||||
},
|
||||
version: "3",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
etag: "0",
|
||||
count: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* A dataset containing the information for the tested user.
|
||||
* To be used during tests.
|
||||
@@ -256,5 +294,6 @@ export const MSK_NOT_CACHED_DATASET: DumpDataSetInfo = {
|
||||
keyQueryResponse: KEY_QUERY_RESPONSE,
|
||||
rotatedKeyQueryResponse: ROTATED_KEY_QUERY_RESPONSE,
|
||||
backupResponse: BACKUP_RESPONSE,
|
||||
newBackupResponse: NEW_BACKUP_RESPONSE,
|
||||
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump/dump.json",
|
||||
};
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/*
|
||||
Copyright 2024 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 { DumpDataSetInfo } from "../index";
|
||||
|
||||
/**
|
||||
|
||||
@@ -857,7 +857,7 @@ describe("AutoDiscovery", function () {
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: AutoDiscoveryAction.FAIL_ERROR,
|
||||
error: AutoDiscovery.ERROR_HOMESERVER_TOO_OLD,
|
||||
error: AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION,
|
||||
base_url: "https://example.org",
|
||||
},
|
||||
"m.identity_server": {
|
||||
|
||||
@@ -50,17 +50,18 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
|
||||
});
|
||||
|
||||
it("Should encode unpadded URL-safe base64", () => {
|
||||
const toEncode = "?????";
|
||||
// Chosen to have padding and multiple instances of / and + in the base64
|
||||
const toEncode = "???????⊕⊗⊗";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const encoded = encodeUnpaddedBase64Url(data);
|
||||
expect(encoded).toEqual("Pz8_Pz8");
|
||||
expect(encoded).toEqual("Pz8_Pz8_P-KKleKKl-KKlw");
|
||||
});
|
||||
|
||||
it("Should decode URL-safe base64", () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8_P-KKleKKl-KKlw=="));
|
||||
|
||||
expect(decoded).toStrictEqual("?????");
|
||||
expect(decoded).toStrictEqual("???????⊕⊗⊗");
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", () => {
|
||||
|
||||
@@ -76,5 +76,27 @@ describe("ContentRepo", function () {
|
||||
baseUrl + "/_matrix/media/v3/download/server.name/resourceid#automade",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return an authenticated URL when requested", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, true, true)).toEqual(
|
||||
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should force-enable allow_redirects when useAuthentication is set true", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, false, true)).toEqual(
|
||||
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,12 @@ import HttpBackend from "matrix-mock-request";
|
||||
import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { MatrixError } from "../../../src/http-api";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
|
||||
import { ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
|
||||
import { CryptoEvent } from "../../../src/crypto";
|
||||
import { IDevice } from "../../../src/crypto/deviceinfo";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo } from "../../../src/crypto-api";
|
||||
|
||||
const PUSH_RULES_RESPONSE: Response = {
|
||||
method: "GET",
|
||||
@@ -377,7 +377,7 @@ describe("Cross Signing", function () {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
const bobSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -515,7 +515,7 @@ describe("Cross Signing", function () {
|
||||
};
|
||||
olmlib.pkSign(bobDevice, selfSigningKey as unknown as PkSigning, "@bob:example.com", "");
|
||||
|
||||
const bobMaster: ICrossSigningKey = {
|
||||
const bobMaster: CrossSigningKeyInfo = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
@@ -630,7 +630,7 @@ describe("Cross Signing", function () {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
const bobSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -696,7 +696,7 @@ describe("Cross Signing", function () {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
const bobSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -763,7 +763,7 @@ describe("Cross Signing", function () {
|
||||
const bobSigning2 = new global.Olm.PkSigning();
|
||||
const bobPrivkey2 = bobSigning2.generate_seed();
|
||||
const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2);
|
||||
const bobSSK2: ICrossSigningKey = {
|
||||
const bobSSK2: CrossSigningKeyInfo = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -913,7 +913,7 @@ describe("Cross Signing", function () {
|
||||
const aliceSigning = new global.Olm.PkSigning();
|
||||
const alicePrivkey = aliceSigning.generate_seed();
|
||||
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
|
||||
const aliceSSK: ICrossSigningKey = {
|
||||
const aliceSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -988,7 +988,7 @@ describe("Cross Signing", function () {
|
||||
const aliceSigning = new global.Olm.PkSigning();
|
||||
const alicePrivkey = aliceSigning.generate_seed();
|
||||
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
|
||||
const aliceSSK: ICrossSigningKey = {
|
||||
const aliceSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -1048,7 +1048,7 @@ describe("Cross Signing", function () {
|
||||
const aliceSigning = new global.Olm.PkSigning();
|
||||
const alicePrivkey = aliceSigning.generate_seed();
|
||||
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
|
||||
const aliceSSK: ICrossSigningKey = {
|
||||
const aliceSSK: CrossSigningKeyInfo = {
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
|
||||
@@ -23,12 +23,13 @@ import { makeTestClients } from "./verification/util";
|
||||
import { encryptAES } from "../../../src/crypto/aes";
|
||||
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client";
|
||||
import { ClientEvent, ICreateClientOpts, MatrixClient } from "../../../src/client";
|
||||
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";
|
||||
import { CrossSigningKeyInfo } from "../../../src/crypto-api";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
@@ -475,7 +476,7 @@ describe("Secrets", function () {
|
||||
[`ed25519:${XSPubKey}`]: XSPubKey,
|
||||
},
|
||||
},
|
||||
self_signing: sign<ICrossSigningKey>(
|
||||
self_signing: sign<CrossSigningKeyInfo>(
|
||||
{
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
@@ -486,7 +487,7 @@ describe("Secrets", function () {
|
||||
XSK,
|
||||
"@alice:example.com",
|
||||
),
|
||||
user_signing: sign<ICrossSigningKey>(
|
||||
user_signing: sign<CrossSigningKeyInfo>(
|
||||
{
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["user_signing"],
|
||||
@@ -631,7 +632,7 @@ describe("Secrets", function () {
|
||||
[`ed25519:${XSPubKey}`]: XSPubKey,
|
||||
},
|
||||
},
|
||||
self_signing: sign<ICrossSigningKey>(
|
||||
self_signing: sign<CrossSigningKeyInfo>(
|
||||
{
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
@@ -642,7 +643,7 @@ describe("Secrets", function () {
|
||||
XSK,
|
||||
"@alice:example.com",
|
||||
),
|
||||
user_signing: sign<ICrossSigningKey>(
|
||||
user_signing: sign<CrossSigningKeyInfo>(
|
||||
{
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["user_signing"],
|
||||
|
||||
@@ -136,11 +136,6 @@ describe("EventTimelineSet", () => {
|
||||
expect(eventsInLiveTimeline.length).toStrictEqual(1);
|
||||
expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent);
|
||||
});
|
||||
|
||||
it("Make sure legacy overload passing options directly as parameters still works", () => {
|
||||
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow();
|
||||
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventToTimeline", () => {
|
||||
|
||||
@@ -386,6 +386,9 @@ describe("MatrixClient", function () {
|
||||
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
|
||||
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
|
||||
);
|
||||
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true, true)).toBe(
|
||||
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true, true),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1557,7 +1560,6 @@ describe("MatrixClient", function () {
|
||||
},
|
||||
},
|
||||
event_id: "$ev1",
|
||||
user_id: "@alice:matrix.org",
|
||||
});
|
||||
|
||||
expect(rootEvent.isThreadRoot).toBe(true);
|
||||
|
||||
@@ -15,16 +15,7 @@ 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",
|
||||
};
|
||||
import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
@@ -34,96 +25,175 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
it("rejects membership with no expiry and no expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(
|
||||
makeMockEvent(),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
|
||||
describe("CallMembershipDataLegacy", () => {
|
||||
const membershipTemplate: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit" }],
|
||||
};
|
||||
it("rejects membership with no expiry and no expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(
|
||||
makeMockEvent(),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 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("allow membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
|
||||
}).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 }),
|
||||
);
|
||||
}).toThrow();
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires_ts", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(1000),
|
||||
Object.assign({}, membershipTemplate, { expires_ts: 6000 }),
|
||||
);
|
||||
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.localTimestamp = Date.now() - 6000;
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns preferred 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.getPreferredFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
describe("SessionMembershipData", () => {
|
||||
const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
focus_active: { type: "livekit" },
|
||||
foci_preferred: [{ type: "livekit" }],
|
||||
};
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: 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 scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
it("allow membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
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 based on expires_ts", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(1000),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
|
||||
);
|
||||
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 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.localTimestamp = Date.now() - 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]);
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
const membershipTemplate: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit" }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
||||
|
||||
describe("LivekitFocus", () => {
|
||||
it("isLivekitFocus", () => {
|
||||
expect(
|
||||
isLivekitFocus({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
livekit_alias: "test",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusActive", () => {
|
||||
expect(
|
||||
isLivekitFocusActive({
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusConfig", () => {
|
||||
expect(
|
||||
isLivekitFocusConfig({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,11 @@ limitations under the License.
|
||||
|
||||
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import {
|
||||
CallMembershipData,
|
||||
CallMembershipDataLegacy,
|
||||
SessionMembershipData,
|
||||
} from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
@@ -29,6 +33,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
|
||||
};
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
@@ -98,22 +103,33 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const roomId = randomString(8);
|
||||
const event = {
|
||||
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),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId: randomString(8),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
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),
|
||||
},
|
||||
],
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -122,22 +138,33 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const roomId = randomString(8);
|
||||
const event = {
|
||||
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),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId: randomString(8),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
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),
|
||||
},
|
||||
],
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -185,6 +212,67 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("updateCallMembershipEvent", () => {
|
||||
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||
const joinSessionConfig = { useLegacyMemberEvents: false };
|
||||
|
||||
const legacyMembershipData: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_legacy",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [mockFocus],
|
||||
};
|
||||
|
||||
const expiredLegacyMembershipData: CallMembershipDataLegacy = {
|
||||
...legacyMembershipData,
|
||||
device_id: "AAAAAAA_legacy_expired",
|
||||
expires: 0,
|
||||
};
|
||||
|
||||
const sessionMembershipData: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_session",
|
||||
focus_active: mockFocus,
|
||||
foci_preferred: [mockFocus],
|
||||
};
|
||||
|
||||
function testSession(
|
||||
membershipData: CallMembershipData[] | SessionMembershipData,
|
||||
shouldUseLegacy: boolean,
|
||||
): void {
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
|
||||
|
||||
const makeNewLegacyMembershipsMock = jest.spyOn(sess as any, "makeNewLegacyMemberships");
|
||||
const makeNewMembershipMock = jest.spyOn(sess as any, "makeNewMembership");
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
|
||||
|
||||
expect(makeNewLegacyMembershipsMock).toHaveBeenCalledTimes(shouldUseLegacy ? 1 : 0);
|
||||
expect(makeNewMembershipMock).toHaveBeenCalledTimes(shouldUseLegacy ? 0 : 1);
|
||||
}
|
||||
|
||||
it("uses legacy events if there are any active legacy calls", () => {
|
||||
testSession([expiredLegacyMembershipData, legacyMembershipData, sessionMembershipData], true);
|
||||
});
|
||||
|
||||
it('uses legacy events if a non-legacy call is in a "memberships" array', () => {
|
||||
testSession([sessionMembershipData], true);
|
||||
});
|
||||
|
||||
it("uses non-legacy events if all legacy calls are expired", () => {
|
||||
testSession([expiredLegacyMembershipData], false);
|
||||
});
|
||||
|
||||
it("uses non-legacy events if there are only non-legacy calls", () => {
|
||||
testSession(sessionMembershipData, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
@@ -198,6 +286,64 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
});
|
||||
it("does not provide focus if the selction method is unknown", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "unknown",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
it("gets the correct active focus legacy", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
@@ -223,13 +369,13 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("shows joined once join is called", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", () => {
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
@@ -242,7 +388,8 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
expires_ts: Date.now() + 3600000,
|
||||
foci_active: [{ type: "mock" }],
|
||||
foci_active: [mockFocus],
|
||||
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
@@ -252,12 +399,48 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("non-legacy calls", () => {
|
||||
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
||||
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
|
||||
|
||||
function testJoin(useOwnedStateEvents: boolean): void {
|
||||
if (useOwnedStateEvents) {
|
||||
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3779.default");
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([activeFocusConfig], activeFocus, { useLegacyMemberEvents: false });
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
foci_preferred: [activeFocusConfig],
|
||||
focus_active: activeFocus,
|
||||
} satisfies SessionMembershipData,
|
||||
`${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`,
|
||||
);
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
it("sends a membership event with session payload when joining a non-legacy call", () => {
|
||||
testJoin(false);
|
||||
});
|
||||
|
||||
it("does not prefix the state key with _ for rooms that support user-owned state events", () => {
|
||||
testJoin(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -274,15 +457,26 @@ describe("MatrixRTCSession", () => {
|
||||
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], 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 event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed);
|
||||
const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
getState.getStateEvents = jest.fn().mockReturnValue(event);
|
||||
getState.events = new Map([
|
||||
[
|
||||
event.getType(),
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
} as unknown as Map<string, MatrixEvent>,
|
||||
],
|
||||
]);
|
||||
|
||||
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
@@ -308,7 +502,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000 * 2,
|
||||
expires_ts: 1000 + 3600000 * 2,
|
||||
foci_active: [{ type: "mock" }],
|
||||
foci_active: [mockFocus],
|
||||
created_ts: 1000,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
@@ -322,7 +516,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("creates a key when joining", () => {
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
@@ -336,7 +530,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
@@ -372,7 +566,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
await eventSentPromise;
|
||||
@@ -394,7 +588,7 @@ describe("MatrixRTCSession", () => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
|
||||
});
|
||||
@@ -409,7 +603,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
@@ -462,7 +656,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
|
||||
@@ -489,7 +683,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("Doesn't re-send key immediately", async () => {
|
||||
const realSetImmediate = setImmediate;
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
@@ -499,7 +693,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
@@ -517,7 +711,7 @@ describe("MatrixRTCSession", () => {
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
realSetImmediate(resolve);
|
||||
realSetTimeout(resolve);
|
||||
});
|
||||
|
||||
expect(sendEventMock).not.toHaveBeenCalled();
|
||||
@@ -595,7 +789,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
sess.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoomNoExpired!.roomId,
|
||||
@@ -631,7 +825,7 @@ describe("MatrixRTCSession", () => {
|
||||
]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
sess.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
@@ -645,6 +839,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 3600000,
|
||||
created_ts: 1000,
|
||||
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "test" }],
|
||||
};
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
@@ -15,24 +15,27 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { CallMembershipData, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
|
||||
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
|
||||
type MembershipData = CallMembershipData[] | SessionMembershipData;
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room {
|
||||
const roomId = randomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(memberships, roomId, localAge);
|
||||
const roomState = makeMockRoomState(membershipData, roomId, localAge);
|
||||
return {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(memberships, roomId, localAge);
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(membershipData, roomId, localAge);
|
||||
return {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
@@ -40,15 +43,30 @@ export function makeMockRoomState(memberships: CallMembershipData[], roomId: str
|
||||
if (stateKey !== undefined) return event;
|
||||
return [event];
|
||||
},
|
||||
events: new Map([
|
||||
[
|
||||
event.getType(),
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
memberships: memberships,
|
||||
}),
|
||||
getContent: jest.fn().mockReturnValue(
|
||||
!Array.isArray(membershipData)
|
||||
? membershipData
|
||||
: {
|
||||
memberships: membershipData,
|
||||
},
|
||||
),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
localTimestamp: Date.now() - (localAge ?? 10),
|
||||
|
||||
@@ -16,11 +16,12 @@ limitations under the License.
|
||||
|
||||
import { IContent, MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event";
|
||||
import { RelationType, UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event";
|
||||
import { EventTimelineSet } from "../../../src/models/event-timeline-set";
|
||||
import { EventTimeline } from "../../../src/models/event-timeline";
|
||||
import { MSC3089Branch } from "../../../src/models/MSC3089Branch";
|
||||
import { MSC3089TreeSpace } from "../../../src/models/MSC3089TreeSpace";
|
||||
import { EncryptedFile } from "../../../src/@types/media";
|
||||
|
||||
describe("MSC3089Branch", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -254,7 +255,7 @@ describe("MSC3089Branch", () => {
|
||||
it("should create new versions of itself", async () => {
|
||||
const canaryName = "canary";
|
||||
const canaryContents = "contents go here";
|
||||
const canaryFile = {} as IEncryptedFile;
|
||||
const canaryFile = {} as EncryptedFile;
|
||||
const canaryAddl = { canary: true };
|
||||
indexEvent.getContent = () => ({ active: true, retained: true });
|
||||
const stateKeyOrder = [fileEventId2, fileEventId];
|
||||
@@ -287,23 +288,21 @@ describe("MSC3089Branch", () => {
|
||||
|
||||
const createFn = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(name: string, contents: ArrayBuffer, info: Partial<IEncryptedFile>, addl: IContent) => {
|
||||
expect(name).toEqual(canaryName);
|
||||
expect(contents).toBe(canaryContents);
|
||||
expect(info).toBe(canaryFile);
|
||||
expect(addl).toMatchObject({
|
||||
...canaryAddl,
|
||||
"m.new_content": true,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: fileEventId,
|
||||
},
|
||||
});
|
||||
.mockImplementation((name: string, contents: ArrayBuffer, info: Partial<EncryptedFile>, addl: IContent) => {
|
||||
expect(name).toEqual(canaryName);
|
||||
expect(contents).toBe(canaryContents);
|
||||
expect(info).toBe(canaryFile);
|
||||
expect(addl).toMatchObject({
|
||||
...canaryAddl,
|
||||
"m.new_content": true,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: fileEventId,
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve({ event_id: fileEventId2 });
|
||||
},
|
||||
);
|
||||
return Promise.resolve({ event_id: fileEventId2 });
|
||||
});
|
||||
directory.createFile = createFn;
|
||||
|
||||
await branch.createNewVersion(canaryName, canaryContents, canaryFile, canaryAddl);
|
||||
|
||||
@@ -90,4 +90,31 @@ describe("registerOidcClient()", () => {
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when required endpoints are unavailable", async () => {
|
||||
await expect(() =>
|
||||
registerOidcClient(
|
||||
{
|
||||
...delegatedAuthConfig,
|
||||
registrationEndpoint: undefined,
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
|
||||
it("should throw when required scopes are unavailable", async () => {
|
||||
await expect(() =>
|
||||
registerOidcClient(
|
||||
{
|
||||
...delegatedAuthConfig,
|
||||
metadata: {
|
||||
...delegatedAuthConfig.metadata,
|
||||
grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]],
|
||||
},
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
});
|
||||
|
||||
+282
-240
@@ -61,251 +61,293 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
let httpBackend: MockHttpBackend;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async function () {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
httpBackend = new MockHttpBackend();
|
||||
/**
|
||||
* We need to split the tests into regular ones (these) and ones that use fake timers,
|
||||
* because the fake indexeddb uses timers too and appears make tests cause other tests
|
||||
* to fail if we keep enabling/disabling fake timers within the same test suite.
|
||||
*/
|
||||
describe("non-timed tests", () => {
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
jest.useRealTimers();
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
describe("async tests", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
let storeStarted = false;
|
||||
idbStore.startup().then(() => {
|
||||
storeStarted = true;
|
||||
});
|
||||
await flushAndRunTimersUntil(() => storeStarted);
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
Copyright 2024 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 { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature(feature: string) {
|
||||
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
baseUrl: "https://example.com",
|
||||
} as unknown as MatrixClient;
|
||||
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
|
||||
baseUrl: client.baseUrl,
|
||||
prefix: ClientPrefix.Unstable,
|
||||
onlyData: true,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
|
||||
describe("MSC4108RendezvousSession", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
async function postAndCheckLocation(msc4108Enabled: boolean, fallbackRzServer: string, locationResponse: string) {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled });
|
||||
const transport = new MSC4108RendezvousSession({ client, fallbackRzServer });
|
||||
{
|
||||
// initial POST
|
||||
const expectedPostLocation = msc4108Enabled
|
||||
? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous`
|
||||
: fallbackRzServer;
|
||||
|
||||
fetchMock.postOnce(expectedPostLocation, {
|
||||
status: 201,
|
||||
body: { url: locationResponse },
|
||||
});
|
||||
await transport.send("data");
|
||||
}
|
||||
|
||||
{
|
||||
fetchMock.get(locationResponse, {
|
||||
status: 200,
|
||||
body: "data",
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
"etag": "aaa",
|
||||
},
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("data");
|
||||
}
|
||||
}
|
||||
|
||||
it("should use custom fetchFn if provided", async () => {
|
||||
const sandbox = fetchMock.sandbox();
|
||||
const fetchFn = jest.fn().mockImplementation(sandbox);
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fetchFn,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
sandbox.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: {
|
||||
url: "https://fallbackserver/rz/123",
|
||||
},
|
||||
});
|
||||
await transport.send("data");
|
||||
await sandbox.flush(true);
|
||||
expect(fetchFn).toHaveBeenCalledWith("https://fallbackserver/rz", expect.anything());
|
||||
});
|
||||
|
||||
it("should throw an error when no server available", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({ client });
|
||||
await expect(transport.send("data")).rejects.toThrow("Invalid rendezvous URI");
|
||||
});
|
||||
|
||||
it("POST to fallback server", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await fetchMock.flush(true);
|
||||
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("POST with no location", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
});
|
||||
await Promise.all([expect(transport.send("data")).rejects.toThrow(), fetchMock.flush(true)]);
|
||||
});
|
||||
|
||||
it("POST with absolute path response", async function () {
|
||||
await postAndCheckLocation(false, "https://fallbackserver/rz", "https://fallbackserver/123");
|
||||
});
|
||||
|
||||
it("POST to built-in MSC3886 implementation", async function () {
|
||||
await postAndCheckLocation(
|
||||
true,
|
||||
"https://fallbackserver/rz",
|
||||
"https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("POST with relative path response including parent", async function () {
|
||||
await postAndCheckLocation(false, "https://fallbackserver/rz/abc", "https://fallbackserver/rz/xyz/123");
|
||||
});
|
||||
|
||||
// fetch-mock doesn't handle redirects properly, so we can't test this
|
||||
it.skip("POST to follow 307 to other server", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 307,
|
||||
redirectUrl: "https://redirected.fallbackserver/rz",
|
||||
redirected: true,
|
||||
});
|
||||
fetchMock.postOnce("https://redirected.fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://redirected.fallbackserver/rz/123" },
|
||||
headers: { etag: "aaa" },
|
||||
});
|
||||
await fetchMock.flush(true);
|
||||
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("POST and GET", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// first GET without etag
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", {
|
||||
status: 200,
|
||||
body: "foo=baa",
|
||||
headers: { "content-type": "text/plain", "etag": "aaa" },
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
{
|
||||
// subsequent GET which should have etag from previous request
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", {
|
||||
status: 200,
|
||||
body: "foo=baa",
|
||||
headers: { "content-type": "text/plain", "etag": "bbb" },
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
|
||||
method: "GET",
|
||||
headers: { "if-none-match": "aaa" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("POST and PUTs", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
headers: { etag: "aaa" },
|
||||
});
|
||||
await transport.send("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// subsequent PUT which should have etag from previous request
|
||||
fetchMock.putOnce("https://fallbackserver/rz/123", { status: 202, headers: { etag: "bbb" } });
|
||||
await transport.send("c=d");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
|
||||
method: "PUT",
|
||||
headers: { "if-match": "aaa" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("POST and DELETE", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// Create
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// Cancel
|
||||
fetchMock.deleteOnce("https://fallbackserver/rz/123", { status: 204 });
|
||||
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("send after cancelled", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
|
||||
await expect(transport.send("data")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("receive before ready", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
await expect(transport.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("404 failure callback", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const onFailure = jest.fn();
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
onFailure,
|
||||
});
|
||||
|
||||
fetchMock.postOnce("https://fallbackserver/rz", { status: 404 });
|
||||
await Promise.all([expect(transport.send("foo=baa")).resolves.toBeUndefined(), fetchMock.flush(true)]);
|
||||
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown);
|
||||
});
|
||||
|
||||
it("404 failure callback mapped to expired", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const onFailure = jest.fn();
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
onFailure,
|
||||
});
|
||||
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
headers: { expires: "Thu, 01 Jan 1970 00:00:00 GMT" },
|
||||
});
|
||||
|
||||
await transport.send("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
{
|
||||
// GET with 404 to simulate expiry
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", { status: 404, body: "foo=baa" });
|
||||
await Promise.all([expect(transport.receive()).resolves.toBeUndefined(), fetchMock.flush(true)]);
|
||||
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
Copyright 2024 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 { EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
|
||||
|
||||
describe("MSC4108SecureChannel", () => {
|
||||
const baseUrl = "https://example.com";
|
||||
const url = "https://fallbackserver/rz/123";
|
||||
|
||||
it("should generate qr code data as expected", async () => {
|
||||
const session = new MSC4108RendezvousSession({
|
||||
url,
|
||||
});
|
||||
const channel = new MSC4108SecureChannel(session);
|
||||
|
||||
const code = await channel.generateCode(QrCodeMode.Login);
|
||||
expect(code).toHaveLength(71);
|
||||
const text = new TextDecoder().decode(code);
|
||||
expect(text.startsWith("MATRIX")).toBeTruthy();
|
||||
expect(text.endsWith(url)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should throw error if attempt to connect multiple times", async () => {
|
||||
const mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await channel.connect();
|
||||
await expect(channel.connect()).rejects.toThrow("Channel already connected");
|
||||
});
|
||||
|
||||
it("should throw error on invalid initiate response", async () => {
|
||||
const mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue("");
|
||||
await expect(channel.connect()).rejects.toThrow("No response from other device");
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await expect(channel.connect()).rejects.toThrow("Invalid response from other device");
|
||||
});
|
||||
|
||||
describe("should be able to connect as a reciprocating device", () => {
|
||||
let mockSession: MSC4108RendezvousSession;
|
||||
let channel: MSC4108SecureChannel;
|
||||
let opponentChannel: EstablishedEcies;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
opponentChannel = _opponentChannel;
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await channel.connect();
|
||||
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK");
|
||||
mocked(mockSession.send).mockReset();
|
||||
});
|
||||
|
||||
it("should be able to securely send encrypted payloads", async () => {
|
||||
const payload = {
|
||||
type: PayloadType.Secrets,
|
||||
protocols: ["a", "b", "c"],
|
||||
homeserver: "https://example.org",
|
||||
};
|
||||
await channel.secureSend(payload);
|
||||
expect(mockSession.send).toHaveBeenCalled();
|
||||
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
it("should be able to securely receive encrypted payloads", async () => {
|
||||
const payload = {
|
||||
type: PayloadType.Secrets,
|
||||
protocols: ["a", "b", "c"],
|
||||
homeserver: "https://example.org",
|
||||
};
|
||||
const ciphertext = opponentChannel.encrypt(JSON.stringify(payload));
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await expect(channel.secureReceive()).resolves.toEqual(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
@@ -17,7 +17,12 @@ limitations under the License.
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import "../../olm-loader";
|
||||
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import {
|
||||
MSC3906Rendezvous,
|
||||
RendezvousCode,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
RendezvousIntent,
|
||||
} from "../../../src/rendezvous";
|
||||
import {
|
||||
ECDHv2RendezvousCode as ECDHRendezvousCode,
|
||||
MSC3903ECDHPayload,
|
||||
@@ -111,7 +116,7 @@ function makeMockClient(opts: {
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
getCachedCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { MatrixClient } from "../../../src";
|
||||
import { RendezvousFailureReason } from "../../../src/rendezvous";
|
||||
import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous";
|
||||
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {
|
||||
|
||||
+45
-21
@@ -22,7 +22,7 @@ import { mocked } from "jest-mock";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, Optional, PollStartEvent } from "matrix-events-sdk";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { emitPromise, IMessageOpts } from "../test-utils/test-utils";
|
||||
import {
|
||||
Direction,
|
||||
DuplicateStrategy,
|
||||
@@ -54,7 +54,6 @@ import { Crypto } from "../../src/crypto";
|
||||
import * as threadUtils from "../test-utils/thread";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { IMessageOpts } from "../test-utils/test-utils";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
|
||||
@@ -339,24 +338,6 @@ describe("Room", function () {
|
||||
}),
|
||||
];
|
||||
|
||||
it("Make sure legacy overload passing options directly as parameters still works", async () => {
|
||||
await expect(room.addLiveEvents(events, DuplicateStrategy.Replace, false)).resolves.not.toThrow();
|
||||
await expect(room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).resolves.not.toThrow();
|
||||
await expect(
|
||||
// @ts-ignore
|
||||
room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", async function () {
|
||||
return expect(
|
||||
// @ts-ignore
|
||||
room.addLiveEvents(events, {
|
||||
duplicateStrategy: "foo",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should replace a timeline event if dupe strategy is 'replace'", async function () {
|
||||
// make a duplicate
|
||||
const dupe = utils.mkMessage({
|
||||
@@ -387,7 +368,7 @@ describe("Room", function () {
|
||||
expect(room.timeline[0]).toEqual(events[0]);
|
||||
// @ts-ignore
|
||||
await room.addLiveEvents([dupe], {
|
||||
duplicateStrategy: "ignore",
|
||||
duplicateStrategy: DuplicateStrategy.Ignore,
|
||||
});
|
||||
expect(room.timeline[0]).toEqual(events[0]);
|
||||
});
|
||||
@@ -4053,4 +4034,47 @@ describe("Room", function () {
|
||||
expect(room.getLastThread()).toBe(thread2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecommendedVersion", () => {
|
||||
it("returns the server's recommended version from capabilities", async () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.getCapabilities = jest.fn().mockReturnValue({
|
||||
["m.room_versions"]: {
|
||||
default: "1",
|
||||
available: ["1", "2"],
|
||||
},
|
||||
});
|
||||
const room = new Room(roomId, client, userA);
|
||||
expect(await room.getRecommendedVersion()).toEqual({
|
||||
version: "1",
|
||||
needsUpgrade: false,
|
||||
urgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.getCapabilities = jest.fn().mockReturnValue({
|
||||
["m.room_versions"]: {
|
||||
default: "5",
|
||||
available: ["5"],
|
||||
},
|
||||
});
|
||||
|
||||
client.fetchCapabilities = jest.fn().mockResolvedValue({
|
||||
["m.room_versions"]: {
|
||||
default: "1",
|
||||
available: ["1"],
|
||||
},
|
||||
});
|
||||
|
||||
const room = new Room(roomId, client, userA);
|
||||
expect(await room.getRecommendedVersion()).toEqual({
|
||||
version: "1",
|
||||
needsUpgrade: false,
|
||||
urgent: false,
|
||||
});
|
||||
expect(client.fetchCapabilities).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
PutDehydratedDeviceRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
UploadSigningKeysRequest,
|
||||
@@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle PutDehydratedDeviceRequest", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new PutDehydratedDeviceRequest(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("PUT", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(
|
||||
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
|
||||
);
|
||||
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);
|
||||
|
||||
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("does not explode with unknown requests", async () => {
|
||||
const outgoingRequest = { id: "5678", type: 987 };
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
mockRustBackupManager = {
|
||||
getActiveBackupVersion: jest.fn(),
|
||||
requestKeyBackupVersion: jest.fn(),
|
||||
getServerBackupInfo: jest.fn(),
|
||||
importBackedUpRoomKeys: jest.fn(),
|
||||
createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor),
|
||||
on: jest.fn().mockImplementation((event, listener) => {
|
||||
@@ -135,7 +135,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
it("Should download and import a missing key from backup", async () => {
|
||||
@@ -155,8 +155,11 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
downloader.onDecryptionKeyMissingError(roomId, sessionId);
|
||||
|
||||
// `isKeyBackupDownloadConfigured` is false until the config is proven.
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
await expectAPICall;
|
||||
await awaitKeyImported.promise;
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
|
||||
expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -313,6 +316,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("Should not query server if backup not active", async () => {
|
||||
@@ -328,6 +334,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("Should stop if backup key is not cached", async () => {
|
||||
@@ -344,6 +353,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("Should stop if backup key cached as wrong version", async () => {
|
||||
@@ -363,6 +375,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("Should stop if backup key version does not match the active one", async () => {
|
||||
@@ -382,13 +397,16 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
|
||||
// isKeyBackupDownloadConfigured remains false
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Backup state update", () => {
|
||||
it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => {
|
||||
// there is a backup
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// but at this point it's not trusted and we don't have the key
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
|
||||
@@ -410,6 +428,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore access to private property
|
||||
expect(downloader.hasConfigurationProblem).toEqual(true);
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
|
||||
|
||||
// Now the backup becomes trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
@@ -423,6 +442,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
mockEmitter.emit(CryptoEvent.KeyBackupStatus, true);
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
|
||||
|
||||
await a0Imported;
|
||||
await a1Imported;
|
||||
@@ -434,7 +454,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
describe("Error cases", () => {
|
||||
beforeEach(async () => {
|
||||
// there is a backup
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
// It's trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
// And we have the key in cache
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("initRustCrypto", () => {
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
|
||||
it("passes through the store params", async () => {
|
||||
it("passes through the store params (passphrase)", async () => {
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
@@ -126,7 +126,30 @@ describe("initRustCrypto", () => {
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase if storePrefix is unset", async () => {
|
||||
it("passes through the store params (key)", async () => {
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "openWithKey").mockResolvedValue(mockStore);
|
||||
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const storeKey = new Uint8Array(32);
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: "storePrefix",
|
||||
storeKey: storeKey,
|
||||
});
|
||||
|
||||
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase and storeKey if storePrefix is unset", async () => {
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
@@ -141,10 +164,11 @@ describe("initRustCrypto", () => {
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: null,
|
||||
storeKey: new Uint8Array(),
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith(undefined, undefined);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith();
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
});
|
||||
|
||||
@@ -205,7 +229,20 @@ describe("initRustCrypto", () => {
|
||||
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
|
||||
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
auth_data: {
|
||||
public_key: "backup_key_public",
|
||||
},
|
||||
version: "45",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
});
|
||||
// The cached key should be valid for the backup
|
||||
const mockBackupDecryptionKey: any = {
|
||||
megolmV1PublicKey: {
|
||||
publicKeyBase64: "backup_key_public",
|
||||
},
|
||||
};
|
||||
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
@@ -291,6 +328,56 @@ describe("initRustCrypto", () => {
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
it("migrates data from a legacy crypto store when secret are not encrypted", async () => {
|
||||
const PICKLE_KEY = "pickle1234";
|
||||
const legacyStore = new MemoryCryptoStore();
|
||||
|
||||
// It's possible for old sessions to directly store the secrets as raw UInt8Array,
|
||||
// so we need to support that in the migration code.
|
||||
// See https://github.com/matrix-org/matrix-js-sdk/commit/c81f11df0afd4d0da3b088892745ae2f8ba1c4a7
|
||||
async function storeSecretKeyInClear(type: string, key: Uint8Array, store: CryptoStore) {
|
||||
// @ts-ignore The API to store raw UInt8Array does not exist anymore, so we need that for this test.
|
||||
store.privateKeys[type as keyof SecretStorePrivateKeys] = key;
|
||||
}
|
||||
|
||||
// Populate the legacy store with some test data
|
||||
const storeSecretKey = (type: string, key: string) =>
|
||||
storeSecretKeyInClear(type, new TextEncoder().encode(key), legacyStore);
|
||||
|
||||
await legacyStore.storeAccount({}, "not a real account");
|
||||
await storeSecretKey("master", "master key");
|
||||
await storeSecretKey("self_signing", "ssk");
|
||||
await storeSecretKey("user_signing", "usk");
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: "storePrefix",
|
||||
storePassphrase: "storePassphrase",
|
||||
legacyCryptoStore: legacyStore,
|
||||
legacyPickleKey: PICKLE_KEY,
|
||||
legacyMigrationProgressListener,
|
||||
});
|
||||
|
||||
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
|
||||
expect(data.pickledAccount).toEqual("not a real account");
|
||||
expect(data.userId!.toString()).toEqual(TEST_USER);
|
||||
expect(data.deviceId!.toString()).toEqual(TEST_DEVICE_ID);
|
||||
expect(atob(data.privateCrossSigningMasterKey!)).toEqual("master key");
|
||||
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
|
||||
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
|
||||
});
|
||||
|
||||
it("handles megolm sessions with no `keysClaimed`", async () => {
|
||||
const legacyStore = new MemoryCryptoStore();
|
||||
legacyStore.storeAccount({}, "not a real account");
|
||||
@@ -762,8 +849,11 @@ describe("RustCrypto", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
|
||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
} else if (
|
||||
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
|
||||
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
|
||||
) {
|
||||
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
|
||||
return;
|
||||
}
|
||||
if (request.id) {
|
||||
@@ -1329,14 +1419,15 @@ describe("RustCrypto", () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
|
||||
const backupVersion = testData.SIGNED_BACKUP_DATA.version!;
|
||||
await olmMachine.enableBackupV1(
|
||||
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
backupVersion,
|
||||
);
|
||||
|
||||
// we import two keys: one "from backup", and one "from export"
|
||||
const [backedUpRoomKey, exportedRoomKey] = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey]);
|
||||
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey], backupVersion);
|
||||
await rustCrypto.importRoomKeys([exportedRoomKey]);
|
||||
|
||||
// we ask for the keys that should be backed up
|
||||
@@ -1371,16 +1462,17 @@ describe("RustCrypto", () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
|
||||
const backupVersion = testData.SIGNED_BACKUP_DATA.version!;
|
||||
await olmMachine.enableBackupV1(
|
||||
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
backupVersion,
|
||||
);
|
||||
|
||||
const backup = Array.from(testData.MEGOLM_SESSION_DATA_ARRAY);
|
||||
// in addition to correct keys, we restore an invalid key
|
||||
backup.push({ room_id: "!roomid", session_id: "sessionid" } as IMegolmSessionData);
|
||||
const progressCallback = jest.fn();
|
||||
await rustCrypto.importBackedUpRoomKeys(backup, { progressCallback });
|
||||
await rustCrypto.importBackedUpRoomKeys(backup, backupVersion, { progressCallback });
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: 3,
|
||||
successes: 0,
|
||||
@@ -1395,6 +1487,72 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("device dehydration", () => {
|
||||
it("should detect if dehydration is supported", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
error: "Unknown endpoint",
|
||||
},
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
device_id: "DEVICE_ID",
|
||||
device_data: "data",
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import & export secrets bundle", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if there is nothing to export", async () => {
|
||||
await expect(rustCrypto.exportSecretsBundle()).rejects.toThrow(
|
||||
"The store doesn't contain any cross-signing keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("should correctly import & export a secrets bundle", async () => {
|
||||
const bundle = {
|
||||
cross_signing: {
|
||||
master_key: "bMnVpkHI4S2wXRxy+IpaKM5PIAUUkl6DE+n0YLIW/qs",
|
||||
user_signing_key: "8tlgLjUrrb/zGJo4YKGhDTIDCEjtJTAS/Sh2AGNLuIo",
|
||||
self_signing_key: "pfDknmP5a0fVVRE54zhkUgJfzbNmvKcNfIWEW796bQs",
|
||||
},
|
||||
backup: {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
key: "bYYv3aFLQ49jMNcOjuTtBY9EKDby2x1m3gfX81nIKRQ",
|
||||
backup_version: "9",
|
||||
},
|
||||
};
|
||||
await rustCrypto.importSecretsBundle(bundle);
|
||||
await expect(rustCrypto.exportSecretsBundle()).resolves.toEqual(expect.objectContaining(bundle));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
@@ -147,11 +147,6 @@ export interface LoginRequest {
|
||||
* The login type being used.
|
||||
*/
|
||||
type: "m.login.password" | "m.login.token" | string;
|
||||
/**
|
||||
* Third-party identifier for the user.
|
||||
* @deprecated in favour of `identifier`.
|
||||
*/
|
||||
address?: string;
|
||||
/**
|
||||
* ID of the client device.
|
||||
* If this does not correspond to a known client device, a new device will be created.
|
||||
|
||||
+10
-5
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { NamespacedValue, UnstableValue } from "../NamespacedValue";
|
||||
import {
|
||||
PolicyRuleEventContent,
|
||||
RoomAvatarEventContent,
|
||||
@@ -56,8 +56,8 @@ import {
|
||||
SDPStreamMetadataKey,
|
||||
} from "../webrtc/callEventTypes";
|
||||
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
|
||||
import { EncryptedFile } from "./media";
|
||||
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
|
||||
import { SessionMembershipData } from "../matrixrtc/CallMembership";
|
||||
|
||||
export enum EventType {
|
||||
// Room state events
|
||||
@@ -299,9 +299,11 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
|
||||
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
|
||||
|
||||
/**
|
||||
* @deprecated in favour of {@link EncryptedFile}
|
||||
* https://github.com/matrix-org/matrix-spec-proposals/pull/4115
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export type IEncryptedFile = EncryptedFile;
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new NamespacedValue("membership", "io.element.msc4115.membership");
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified non-state room events.
|
||||
@@ -355,7 +357,10 @@ export interface StateEvents {
|
||||
|
||||
// MSC3401
|
||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
|
||||
[EventType.GroupCallMemberPrefix]: XOR<
|
||||
XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>,
|
||||
XOR<SessionMembershipData, {}>
|
||||
>;
|
||||
|
||||
// MSC3089
|
||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
declare module "@matrix-org/matrix-sdk-crypto-wasm" {
|
||||
interface OlmMachine {
|
||||
importSecretsBundle(bundle: RustSdkCryptoJs.SecretsBundle): Promise<void>;
|
||||
exportSecretsBundle(): Promise<RustSdkCryptoJs.SecretsBundle>;
|
||||
}
|
||||
|
||||
interface SecretsBundle {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
to_json(): Promise<{
|
||||
cross_signing: {
|
||||
master_key: string;
|
||||
self_signing_key: string;
|
||||
user_signing_key: string;
|
||||
};
|
||||
backup?: {
|
||||
algorithm: string;
|
||||
key: string;
|
||||
backup_version: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
|
||||
}
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 2024 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 "oidc-client-ts";
|
||||
|
||||
declare module "oidc-client-ts" {
|
||||
interface OidcMetadata {
|
||||
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
|
||||
device_authorization_endpoint?: string;
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,6 @@ export enum RestrictedAllowType {
|
||||
RoomMembership = "m.room_membership",
|
||||
}
|
||||
|
||||
export interface IJoinRuleEventContent {
|
||||
join_rule: JoinRule; // eslint-disable-line camelcase
|
||||
allow?: {
|
||||
type: RestrictedAllowType;
|
||||
room_id: string; // eslint-disable-line camelcase
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum GuestAccess {
|
||||
CanJoin = "can_join",
|
||||
Forbidden = "forbidden",
|
||||
|
||||
@@ -52,21 +52,13 @@ export interface RegisterRequest {
|
||||
*/
|
||||
initial_device_display_name?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
* Guest users can also upgrade their account by going through the ordinary register flow,
|
||||
* but specifying the additional POST parameter guest_access_token containing the guest’s access token.
|
||||
* They are also required to specify the username parameter to the value of the local part of their username,
|
||||
* which is otherwise optional.
|
||||
* @see https://spec.matrix.org/v1.10/client-server-api/#guest-access
|
||||
*/
|
||||
guest_access_token?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
x_show_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_email?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,10 +99,4 @@ export interface RegisterResponse {
|
||||
* Omitted if the inhibit_login option is true.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
/**
|
||||
* The server_name of the homeserver on which the account has been registered.
|
||||
*
|
||||
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
|
||||
*/
|
||||
home_server?: string;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ enum GroupKey {
|
||||
}
|
||||
|
||||
export interface IResultRoomEvents {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: ISearchResult[];
|
||||
count?: number;
|
||||
highlights?: string[];
|
||||
results?: ISearchResult[];
|
||||
state?: { [roomId: string]: IStateEventWithRoomId[] };
|
||||
groups?: {
|
||||
[groupKey in GroupKey]: {
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { RoomType } from "./event";
|
||||
import { GuestAccess, HistoryVisibility, RestrictedAllowType } from "./partials";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "./partials";
|
||||
import { ImageInfo } from "./media";
|
||||
import { PolicyRecommendation } from "../models/invites-ignorer";
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface RoomCreateEventContent {
|
||||
}
|
||||
|
||||
export interface RoomJoinRulesEventContent {
|
||||
join_rule: JoinRule;
|
||||
allow?: {
|
||||
room_id: string;
|
||||
type: RestrictedAllowType;
|
||||
@@ -94,7 +95,9 @@ export interface RoomTopicEventContent {
|
||||
|
||||
export interface RoomAvatarEventContent {
|
||||
url?: string;
|
||||
info?: ImageInfo;
|
||||
// The spec says that an encrypted file can be used for the thumbnail but this isn't true
|
||||
// https://github.com/matrix-org/matrix-spec/issues/562 so omit those fields
|
||||
info?: Omit<ImageInfo, "thumbnail_file">;
|
||||
}
|
||||
|
||||
export interface RoomPinnedEventsEventContent {
|
||||
|
||||
@@ -43,8 +43,6 @@ export enum AutoDiscoveryError {
|
||||
InvalidJson = "Invalid JSON",
|
||||
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
|
||||
|
||||
/** @deprecated Replaced by `UnsupportedHomeserverSpecVersion` */
|
||||
HomeserverTooOld = UnsupportedHomeserverSpecVersion,
|
||||
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
|
||||
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
|
||||
}
|
||||
@@ -91,9 +89,6 @@ export class AutoDiscovery {
|
||||
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
|
||||
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
|
||||
|
||||
/** @deprecated Replaced by ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION */
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
|
||||
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): stri
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64Url(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeUnpaddedBase64(uint8Array).replace("+", "-").replace("/", "_");
|
||||
return encodeUnpaddedBase64(uint8Array).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ export function decodeBase64(base64: string): Uint8Array {
|
||||
const itFunc = function* (): Generator<number> {
|
||||
const decoded = atob(
|
||||
// built-in atob doesn't support base64url: convert so we support either
|
||||
base64.replace("-", "+").replace("_", "/"),
|
||||
base64.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
);
|
||||
for (let i = 0; i < decoded.length; ++i) {
|
||||
yield decoded.charCodeAt(i);
|
||||
|
||||
+138
-151
@@ -226,6 +226,7 @@ import { getRelationsThreadFilter } from "./thread-utils";
|
||||
import { KnownMembership, Membership } from "./@types/membership";
|
||||
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
|
||||
import { ImageInfo } from "./@types/media";
|
||||
import { Capabilities, ServerCapabilities } from "./serverCapabilities";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -233,7 +234,6 @@ export type ResetTimelineCallback = (roomId: string) => boolean;
|
||||
|
||||
const SCROLLBACK_DELAY_MS = 3000;
|
||||
export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
|
||||
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
|
||||
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
|
||||
|
||||
export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
|
||||
@@ -357,14 +357,14 @@ export interface ICreateClientOpts {
|
||||
deviceToImport?: IExportedDevice;
|
||||
|
||||
/**
|
||||
* Encryption key used for encrypting sensitive data (such as e2ee keys) in storage.
|
||||
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
|
||||
*
|
||||
* This must be set to the same value every time the client is initialised for the same device.
|
||||
*
|
||||
* If unset, either a hardcoded key or no encryption at all is used, depending on the Crypto implementation.
|
||||
*
|
||||
* No particular requirement is placed on the key data (it is fed into an HKDF to generate the actual encryption
|
||||
* keys).
|
||||
* This is only used for the legacy crypto implementation (as used by {@link MatrixClient#initCrypto}),
|
||||
* but if you use the rust crypto implementation ({@link MatrixClient#initRustCrypto}) and the device
|
||||
* previously used legacy crypto (so must be migrated), then this must still be provided, so that the
|
||||
* data can be migrated from the legacy store.
|
||||
*/
|
||||
pickleKey?: string;
|
||||
|
||||
@@ -518,26 +518,6 @@ export interface IStartClientOpts {
|
||||
|
||||
export interface IStoredClientOpts extends IStartClientOpts {}
|
||||
|
||||
export enum RoomVersionStability {
|
||||
Stable = "stable",
|
||||
Unstable = "unstable",
|
||||
}
|
||||
|
||||
export interface IRoomVersionsCapability {
|
||||
default: string;
|
||||
available: Record<string, RoomVersionStability>;
|
||||
}
|
||||
|
||||
export interface ICapability {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IChangePasswordCapability extends ICapability {}
|
||||
|
||||
export interface IThreadsCapability extends ICapability {}
|
||||
|
||||
export interface IGetLoginTokenCapability extends ICapability {}
|
||||
|
||||
export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
|
||||
"m.get_login_token",
|
||||
"org.matrix.msc3882.get_login_token",
|
||||
@@ -547,29 +527,13 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
|
||||
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
|
||||
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
|
||||
|
||||
/**
|
||||
* A representation of the capabilities advertised by a homeserver as defined by
|
||||
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
|
||||
*/
|
||||
export interface Capabilities {
|
||||
[key: string]: any;
|
||||
"m.change_password"?: IChangePasswordCapability;
|
||||
"m.room_versions"?: IRoomVersionsCapability;
|
||||
"io.element.thread"?: IThreadsCapability;
|
||||
"m.get_login_token"?: IGetLoginTokenCapability;
|
||||
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
|
||||
}
|
||||
|
||||
/** @deprecated prefer {@link CrossSigningKeyInfo}. */
|
||||
export type ICrossSigningKey = CrossSigningKeyInfo;
|
||||
|
||||
enum CrossSigningKeyType {
|
||||
MasterKey = "master_key",
|
||||
SelfSigningKey = "self_signing_key",
|
||||
UserSigningKey = "user_signing_key",
|
||||
}
|
||||
|
||||
export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
|
||||
export type CrossSigningKeys = Record<CrossSigningKeyType, CrossSigningKeyInfo>;
|
||||
|
||||
export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>;
|
||||
|
||||
@@ -581,7 +545,7 @@ export interface ISignedKey {
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>;
|
||||
export type KeySignatures = Record<string, Record<string, CrossSigningKeyInfo | ISignedKey>>;
|
||||
export interface IUploadKeySignaturesResponse {
|
||||
failures: Record<
|
||||
string,
|
||||
@@ -865,10 +829,24 @@ interface IThirdPartyUser {
|
||||
fields: object;
|
||||
}
|
||||
|
||||
interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
|
||||
room_type?: RoomType;
|
||||
membership?: Membership;
|
||||
is_encrypted: boolean;
|
||||
/**
|
||||
* The summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
|
||||
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
|
||||
*/
|
||||
export interface RoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
|
||||
/**
|
||||
* The current membership of this user in the room.
|
||||
* Usually "leave" if the room is fetched over federation.
|
||||
*/
|
||||
"membership"?: Membership;
|
||||
/**
|
||||
* Version of the room.
|
||||
*/
|
||||
"im.nheko.summary.room_version"?: string;
|
||||
/**
|
||||
* The encryption algorithm used for this room, if the room is encrypted.
|
||||
*/
|
||||
"im.nheko.summary.encryption"?: string;
|
||||
}
|
||||
|
||||
interface IRoomKeysResponse {
|
||||
@@ -1282,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
|
||||
protected serverVersionsPromise?: Promise<IServerVersions>;
|
||||
|
||||
public cachedCapabilities?: {
|
||||
capabilities: Capabilities;
|
||||
expiration: number;
|
||||
};
|
||||
protected clientWellKnown?: IClientWellKnown;
|
||||
protected clientWellKnownPromise?: Promise<IClientWellKnown>;
|
||||
protected turnServers: ITurnServer[] = [];
|
||||
@@ -1314,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
public readonly matrixRTC: MatrixRTCSessionManager;
|
||||
|
||||
private serverCapabilitiesService: ServerCapabilities;
|
||||
|
||||
public constructor(opts: IMatrixClientCreateOpts) {
|
||||
super();
|
||||
|
||||
@@ -1407,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.http);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
this.timelineSupport = Boolean(opts.timelineSupport);
|
||||
@@ -1469,13 +1447,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
this.on(ClientEvent.Sync, this.startMatrixRTC);
|
||||
|
||||
// backwards compat for when 'opts' was 'historyLen'.
|
||||
if (typeof opts === "number") {
|
||||
opts = {
|
||||
initialSyncLimit: opts,
|
||||
};
|
||||
}
|
||||
|
||||
// Create our own user object artificially (instead of waiting for sync)
|
||||
// so it's always available, even if the user is not in any rooms etc.
|
||||
const userId = this.getUserId();
|
||||
@@ -1536,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.start();
|
||||
this.serverCapabilitiesService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1589,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.toDeviceMessageQueue.stop();
|
||||
|
||||
this.matrixRTC.stop();
|
||||
|
||||
this.serverCapabilitiesService.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2091,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the homeserver. Always returns an object of
|
||||
* capability keys and their options, which may be empty.
|
||||
* @param fresh - True to ignore any cached values.
|
||||
* @returns Promise which resolves to the capabilities of the homeserver
|
||||
* @returns Rejects: with an error response.
|
||||
* Gets the cached capabilities of the homeserver, returning cached ones if available.
|
||||
* If there are no cached capabilities and none can be fetched, throw an exception.
|
||||
*
|
||||
* @returns Promise resolving with The capabilities of the homeserver
|
||||
*/
|
||||
public getCapabilities(fresh = false): Promise<Capabilities> {
|
||||
const now = new Date().getTime();
|
||||
public async getCapabilities(): Promise<Capabilities> {
|
||||
const caps = this.serverCapabilitiesService.getCachedCapabilities();
|
||||
if (caps) return caps;
|
||||
return this.serverCapabilitiesService.fetchCapabilities();
|
||||
}
|
||||
|
||||
if (this.cachedCapabilities && !fresh) {
|
||||
if (now < this.cachedCapabilities.expiration) {
|
||||
this.logger.debug("Returning cached capabilities");
|
||||
return Promise.resolve(this.cachedCapabilities.capabilities);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Gets the cached capabilities of the homeserver. If none have been fetched yet,
|
||||
* return undefined.
|
||||
*
|
||||
* @returns The capabilities of the homeserver
|
||||
*/
|
||||
public getCachedCapabilities(): Capabilities | undefined {
|
||||
return this.serverCapabilitiesService.getCachedCapabilities();
|
||||
}
|
||||
|
||||
type Response = {
|
||||
capabilities?: Capabilities;
|
||||
};
|
||||
return this.http
|
||||
.authedRequest<Response>(Method.Get, "/capabilities")
|
||||
.catch((e: Error): Response => {
|
||||
// We swallow errors because we need a default object anyhow
|
||||
this.logger.error(e);
|
||||
return {};
|
||||
})
|
||||
.then((r = {}) => {
|
||||
const capabilities = r["capabilities"] || {};
|
||||
|
||||
// If the capabilities missed the cache, cache it for a shorter amount
|
||||
// of time to try and refresh them later.
|
||||
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
|
||||
|
||||
this.cachedCapabilities = {
|
||||
capabilities,
|
||||
expiration: now + cacheMs,
|
||||
};
|
||||
|
||||
this.logger.debug("Caching capabilities: ", capabilities);
|
||||
return capabilities;
|
||||
});
|
||||
/**
|
||||
* Fetches the latest capabilities from the homeserver, ignoring any cached
|
||||
* versions. The newly returned version is cached.
|
||||
*
|
||||
* @returns A promise which resolves to the capabilities of the homeserver
|
||||
*/
|
||||
public fetchCapabilities(): Promise<Capabilities> {
|
||||
return this.serverCapabilitiesService.fetchCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2218,17 +2180,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* An alternative to {@link initCrypto}.
|
||||
*
|
||||
* *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
|
||||
* Eventually it will be deprecated and `initCrypto` will do the same thing.
|
||||
*
|
||||
* @experimental
|
||||
*
|
||||
* @param useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
|
||||
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
|
||||
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
|
||||
* @param args.storagePassword - An alternative to `storageKey`. A password which will be used to derive a key to
|
||||
* encrypt the store with. Deriving a key from a password is (deliberately) a slow operation, so prefer
|
||||
* to pass a `storageKey` directly where possible.
|
||||
*
|
||||
* @returns a Promise which will resolve when the crypto layer has been
|
||||
* successfully initialised.
|
||||
*/
|
||||
public async initRustCrypto({ useIndexedDB = true }: { useIndexedDB?: boolean } = {}): Promise<void> {
|
||||
public async initRustCrypto(
|
||||
args: {
|
||||
useIndexedDB?: boolean;
|
||||
storageKey?: Uint8Array;
|
||||
storagePassword?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (this.cryptoBackend) {
|
||||
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
@@ -2261,11 +2230,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceId: deviceId,
|
||||
secretStorage: this.secretStorage,
|
||||
cryptoCallbacks: this.cryptoCallbacks,
|
||||
storePrefix: useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
|
||||
storePassphrase: this.pickleKey,
|
||||
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
|
||||
storeKey: args.storageKey,
|
||||
|
||||
// temporary compatibility hack: if there is no storageKey nor storagePassword, fall back to the pickleKey
|
||||
storePassphrase: args.storagePassword ?? this.pickleKey,
|
||||
|
||||
legacyCryptoStore: this.cryptoStore,
|
||||
legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY",
|
||||
legacyMigrationProgressListener: (progress, total) => {
|
||||
legacyMigrationProgressListener: (progress: number, total: number): void => {
|
||||
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
|
||||
},
|
||||
});
|
||||
@@ -2884,7 +2857,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param event - event to be checked
|
||||
* @returns The event information.
|
||||
* @deprecated Prefer {@link CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}.
|
||||
* @deprecated Prefer {@link Crypto.CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}.
|
||||
*/
|
||||
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
|
||||
if (!this.cryptoBackend) {
|
||||
@@ -3302,7 +3275,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* trust information (as returned by isKeyBackupTrusted)
|
||||
* in trustInfo.
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
* @deprecated Prefer {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
|
||||
if (!this.crypto) {
|
||||
@@ -3359,7 +3332,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* the server, otherwise false. If we haven't completed a successful check
|
||||
* of key backup status yet, returns null.
|
||||
*
|
||||
* @deprecated Prefer direct access to {@link CryptoApi.getActiveSessionBackupVersion}:
|
||||
* @deprecated Prefer direct access to {@link Crypto.CryptoApi.getActiveSessionBackupVersion}:
|
||||
*
|
||||
* ```javascript
|
||||
* let enabled = (await client.getCrypto().getActiveSessionBackupVersion()) !== null;
|
||||
@@ -3379,7 +3352,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param info - Backup information object as returned by getKeyBackupVersion
|
||||
* @returns Promise which resolves when complete.
|
||||
*
|
||||
* @deprecated Do not call this directly. Instead call {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
* @deprecated Do not call this directly. Instead call {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
@@ -3841,12 +3814,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!backupInfo.version) {
|
||||
throw new Error("Backup version must be defined");
|
||||
}
|
||||
const backupVersion = backupInfo.version!;
|
||||
|
||||
let totalKeyCount = 0;
|
||||
let totalFailures = 0;
|
||||
let totalImported = 0;
|
||||
|
||||
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
|
||||
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupVersion);
|
||||
|
||||
const backupDecryptor = await this.cryptoBackend.getBackupDecryptor(backupInfo, privKey);
|
||||
|
||||
@@ -3860,7 +3834,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// Cache the key, if possible.
|
||||
// This is async.
|
||||
this.cryptoBackend
|
||||
.storeSessionBackupPrivateKey(privKey, backupInfo.version)
|
||||
.storeSessionBackupPrivateKey(privKey, backupVersion)
|
||||
.catch((e) => {
|
||||
this.logger.warn("Error caching session backup key:", e);
|
||||
})
|
||||
@@ -3900,7 +3874,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
async (chunk) => {
|
||||
// We have a chunk of decrypted keys: import them
|
||||
try {
|
||||
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, {
|
||||
const backupVersion = backupInfo.version!;
|
||||
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, backupVersion, {
|
||||
untrusted,
|
||||
});
|
||||
totalImported += chunk.length;
|
||||
@@ -3930,7 +3905,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId!;
|
||||
}
|
||||
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
|
||||
await this.cryptoBackend.importBackedUpRoomKeys(keys, backupVersion, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
});
|
||||
@@ -3944,7 +3919,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
key.room_id = targetRoomId!;
|
||||
key.session_id = targetSessionId!;
|
||||
|
||||
await this.cryptoBackend.importBackedUpRoomKeys([key], {
|
||||
await this.cryptoBackend.importBackedUpRoomKeys([key], backupVersion, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
});
|
||||
@@ -4302,9 +4277,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
|
||||
}
|
||||
|
||||
const queryString: Record<string, string | string[]> = {};
|
||||
let queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
queryString["server_name"] = opts.viaServers;
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
|
||||
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
|
||||
}
|
||||
}
|
||||
|
||||
const data: IJoinRequestBody = {};
|
||||
@@ -4314,7 +4293,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
// In case we were originally given an alias, check the room cache again
|
||||
@@ -4347,9 +4326,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
|
||||
|
||||
const queryParams: Record<string, string | string[]> = {};
|
||||
let queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
|
||||
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
@@ -4862,10 +4845,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
|
||||
}
|
||||
path = utils.encodeUri(pathTemplate, pathParams);
|
||||
} else if (event.isRedaction()) {
|
||||
} else if (event.isRedaction() && event.event.redacts) {
|
||||
const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
|
||||
path = utils.encodeUri(pathTemplate, {
|
||||
$redactsEventId: event.event.redacts!,
|
||||
$redactsEventId: event.event.redacts,
|
||||
...pathParams,
|
||||
});
|
||||
} else {
|
||||
@@ -5761,7 +5744,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* anyone they share a room with. If false, will return null for such URLs.
|
||||
* @param allowRedirects - If true, the caller supports the URL being 307 or
|
||||
* 308 redirected to another resource upon request. If false, redirects
|
||||
* are not expected.
|
||||
* are not expected. Implied `true` when `useAuthentication` is `true`.
|
||||
* @param useAuthentication - If true, the caller supports authenticated
|
||||
* media and wants an authentication-required URL. Note that server support
|
||||
* for authenticated media will *not* be checked - it is the caller's responsibility
|
||||
* to do so before calling this function. Note also that `useAuthentication`
|
||||
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
|
||||
* @returns the avatar URL or null.
|
||||
*/
|
||||
public mxcUrlToHttp(
|
||||
@@ -5771,8 +5759,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
resizeMethod?: string,
|
||||
allowDirectLinks?: boolean,
|
||||
allowRedirects?: boolean,
|
||||
useAuthentication?: boolean,
|
||||
): string | null {
|
||||
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects);
|
||||
return getHttpUriForMxc(
|
||||
this.baseUrl,
|
||||
mxcUrl,
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
allowDirectLinks,
|
||||
allowRedirects,
|
||||
useAuthentication,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7077,7 +7075,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// append the new results to our existing results
|
||||
const resultsLength = roomEvents.results?.length ?? 0;
|
||||
for (let i = 0; i < resultsLength; i++) {
|
||||
const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
|
||||
const sr = SearchResult.fromJson(roomEvents.results![i], mapper);
|
||||
const room = this.getRoom(sr.context.getEvent().getRoomId());
|
||||
if (room) {
|
||||
// Copy over a known event sender if we can
|
||||
@@ -7862,16 +7860,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
password: string,
|
||||
sessionId: string | null,
|
||||
auth: { session?: string; type: string },
|
||||
bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean },
|
||||
bindThreepids?: { email?: boolean; msisdn?: boolean },
|
||||
guestAccessToken?: string,
|
||||
inhibitLogin?: boolean,
|
||||
): Promise<RegisterResponse> {
|
||||
// backwards compat
|
||||
if (bindThreepids === true) {
|
||||
bindThreepids = { email: true };
|
||||
} else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) {
|
||||
bindThreepids = {};
|
||||
}
|
||||
if (sessionId) {
|
||||
auth.session = sessionId;
|
||||
}
|
||||
@@ -7886,27 +7878,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (password !== undefined && password !== null) {
|
||||
params.password = password;
|
||||
}
|
||||
if (bindThreepids.email) {
|
||||
params.bind_email = true;
|
||||
}
|
||||
if (bindThreepids.msisdn) {
|
||||
params.bind_msisdn = true;
|
||||
}
|
||||
if (guestAccessToken !== undefined && guestAccessToken !== null) {
|
||||
params.guest_access_token = guestAccessToken;
|
||||
}
|
||||
if (inhibitLogin !== undefined && inhibitLogin !== null) {
|
||||
params.inhibit_login = inhibitLogin;
|
||||
}
|
||||
// Temporary parameter added to make the register endpoint advertise
|
||||
// msisdn flows. This exists because there are clients that break
|
||||
// when given stages they don't recognise. This parameter will cease
|
||||
// to be necessary once these old clients are gone.
|
||||
// Only send it if we send any params at all (the password param is
|
||||
// mandatory, so if we send any params, we'll send the password param)
|
||||
if (password !== undefined && password !== null) {
|
||||
params.x_show_msisdn = true;
|
||||
}
|
||||
|
||||
return this.registerRequest(params);
|
||||
}
|
||||
@@ -9786,11 +9763,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param roomIdOrAlias - The ID or alias of the room to get the summary of.
|
||||
* @param via - The list of servers which know about the room if only an ID was provided.
|
||||
*/
|
||||
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
|
||||
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
|
||||
return this.http.authedRequest(Method.Get, path, { via }, undefined, {
|
||||
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<RoomSummary> {
|
||||
const paramOpts = {
|
||||
prefix: "/_matrix/client/unstable/im.nheko.summary",
|
||||
});
|
||||
};
|
||||
try {
|
||||
const path = utils.encodeUri("/summary/$roomid", { $roomid: roomIdOrAlias });
|
||||
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
|
||||
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
|
||||
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,10 +113,11 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* Import a list of room keys restored from backup
|
||||
*
|
||||
* @param keys - a list of session export objects
|
||||
* @param backupVersion - the version of the backup these keys came from.
|
||||
* @param opts - options object
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
|
||||
+27
-3
@@ -30,7 +30,12 @@ import { encodeParams } from "./utils";
|
||||
* for such URLs.
|
||||
* @param allowRedirects - If true, the caller supports the URL being 307 or
|
||||
* 308 redirected to another resource upon request. If false, redirects
|
||||
* are not expected.
|
||||
* are not expected. Implied `true` when `useAuthentication` is `true`.
|
||||
* @param useAuthentication - If true, the caller supports authenticated
|
||||
* media and wants an authentication-required URL. Note that server support
|
||||
* for authenticated media will *not* be checked - it is the caller's responsibility
|
||||
* to do so before calling this function. Note also that `useAuthentication`
|
||||
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
|
||||
* @returns The complete URL to the content.
|
||||
*/
|
||||
export function getHttpUriForMxc(
|
||||
@@ -41,6 +46,7 @@ export function getHttpUriForMxc(
|
||||
resizeMethod?: string,
|
||||
allowDirectLinks = false,
|
||||
allowRedirects?: boolean,
|
||||
useAuthentication?: boolean,
|
||||
): string {
|
||||
if (typeof mxc !== "string" || !mxc) {
|
||||
return "";
|
||||
@@ -52,8 +58,22 @@ export function getHttpUriForMxc(
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
if (useAuthentication) {
|
||||
allowRedirects = true; // per docs (MSC3916 always expects redirects)
|
||||
|
||||
// Dev note: MSC3916 removes `allow_redirect` entirely, but
|
||||
// for explicitness we set it here. This makes it slightly more obvious to
|
||||
// callers, hopefully.
|
||||
}
|
||||
|
||||
let serverAndMediaId = mxc.slice(6); // strips mxc://
|
||||
let prefix = "/_matrix/media/v3/download/";
|
||||
let prefix: string;
|
||||
if (useAuthentication) {
|
||||
prefix = "/_matrix/client/v1/media/download/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/download/";
|
||||
}
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (width) {
|
||||
@@ -68,7 +88,11 @@ export function getHttpUriForMxc(
|
||||
if (Object.keys(params).length > 0) {
|
||||
// these are thumbnailing params so they probably want the
|
||||
// thumbnailing API...
|
||||
prefix = "/_matrix/media/v3/thumbnail/";
|
||||
if (useAuthentication) {
|
||||
prefix = "/_matrix/client/v1/media/thumbnail/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/thumbnail/";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof allowRedirects === "boolean") {
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
@@ -496,6 +497,59 @@ export interface CryptoApi {
|
||||
* @param version - The backup version to delete.
|
||||
*/
|
||||
deleteKeyBackupVersion(version: string): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Dehydrated devices
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Returns whether MSC3814 dehydrated devices are supported by the crypto
|
||||
* backend and by the server.
|
||||
*
|
||||
* This should be called before calling `startDehydration`, and if this
|
||||
* returns `false`, `startDehydration` should not be called.
|
||||
*/
|
||||
isDehydrationSupported(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Start using device dehydration.
|
||||
*
|
||||
* - Rehydrates a dehydrated device, if one is available.
|
||||
* - Creates a new dehydration key, if necessary, and stores it in Secret
|
||||
* Storage.
|
||||
* - If `createNewKey` is set to true, always creates a new key.
|
||||
* - If a dehydration key is not available, creates a new one.
|
||||
* - Creates a new dehydrated device, and schedules periodically creating
|
||||
* new dehydrated devices.
|
||||
*
|
||||
* This function must not be called unless `isDehydrationSupported` returns
|
||||
* `true`, and must not be called until after cross-signing and secret
|
||||
* storage have been set up.
|
||||
*
|
||||
* @param createNewKey - whether to force creation of a new dehydration key.
|
||||
* This can be used, for example, if Secret Storage is being reset. Defaults
|
||||
* to false.
|
||||
*/
|
||||
startDehydration(createNewKey?: boolean): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Import/export of secret keys
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Export secrets bundle for transmitting to another device as part of OIDC QR login
|
||||
*/
|
||||
exportSecretsBundle?(): Promise<Awaited<ReturnType<SecretsBundle["to_json"]>>>;
|
||||
|
||||
/**
|
||||
* Import secrets bundle transmitted from another device.
|
||||
* @param secrets - The secrets bundle received from the other device
|
||||
*/
|
||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
@@ -506,6 +560,29 @@ export enum DecryptionFailureCode {
|
||||
/** Message was encrypted with a Megolm session which has been shared with us, but in a later ratchet state. */
|
||||
OLM_UNKNOWN_MESSAGE_INDEX = "OLM_UNKNOWN_MESSAGE_INDEX",
|
||||
|
||||
/**
|
||||
* Message was sent before the current device was created; there is no key backup on the server, so this
|
||||
* decryption failure is expected.
|
||||
*/
|
||||
HISTORICAL_MESSAGE_NO_KEY_BACKUP = "HISTORICAL_MESSAGE_NO_KEY_BACKUP",
|
||||
|
||||
/**
|
||||
* Message was sent before the current device was created; there was a key backup on the server, but we don't
|
||||
* seem to have access to the backup. (Probably we don't have the right key.)
|
||||
*/
|
||||
HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED = "HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED",
|
||||
|
||||
/**
|
||||
* Message was sent before the current device was created; there was a (usable) key backup on the server, but we
|
||||
* still can't decrypt. (Either the session isn't in the backup, or we just haven't gotten around to checking yet.)
|
||||
*/
|
||||
HISTORICAL_MESSAGE_WORKING_BACKUP = "HISTORICAL_MESSAGE_WORKING_BACKUP",
|
||||
|
||||
/**
|
||||
* Message was sent when the user was not a member of the room.
|
||||
*/
|
||||
HISTORICAL_MESSAGE_USER_NOT_JOINED = "HISTORICAL_MESSAGE_USER_NOT_JOINED",
|
||||
|
||||
/** Unknown or unclassified error. */
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface BackupTrustInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
* The result of {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
export interface KeyBackupCheck {
|
||||
backupInfo: KeyBackupInfo;
|
||||
|
||||
@@ -24,13 +24,13 @@ import { logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
|
||||
import { ISignedKey, MatrixClient } from "../client";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import { ICryptoCallbacks } from ".";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
|
||||
import { CrossSigningKeyInfo, DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
|
||||
// backwards-compatibility re-exports
|
||||
@@ -38,7 +38,7 @@ export { UserTrustLevel };
|
||||
|
||||
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
|
||||
|
||||
function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string {
|
||||
function publicKeyFromKeyInfo(keyInfo: CrossSigningKeyInfo): string {
|
||||
// `keys` is an object with { [`ed25519:${pubKey}`]: pubKey }
|
||||
// We assume only a single key, and we want the bare form without type
|
||||
// prefix, so we select the values.
|
||||
@@ -51,13 +51,13 @@ export interface ICacheCallbacks {
|
||||
}
|
||||
|
||||
export interface ICrossSigningInfo {
|
||||
keys: Record<string, ICrossSigningKey>;
|
||||
keys: Record<string, CrossSigningKeyInfo>;
|
||||
firstUse: boolean;
|
||||
crossSigningVerifiedBefore: boolean;
|
||||
}
|
||||
|
||||
export class CrossSigningInfo {
|
||||
public keys: Record<string, ICrossSigningKey> = {};
|
||||
public keys: Record<string, CrossSigningKeyInfo> = {};
|
||||
public firstUse = true;
|
||||
// This tracks whether we've ever verified this user with any identity.
|
||||
// When you verify a user, any devices online at the time that receive
|
||||
@@ -296,7 +296,7 @@ export class CrossSigningInfo {
|
||||
}
|
||||
|
||||
const privateKeys: Record<string, Uint8Array> = {};
|
||||
const keys: Record<string, ICrossSigningKey> = {};
|
||||
const keys: Record<string, CrossSigningKeyInfo> = {};
|
||||
let masterSigning: PkSigning | undefined;
|
||||
let masterPub: string | undefined;
|
||||
|
||||
@@ -368,8 +368,8 @@ export class CrossSigningInfo {
|
||||
this.keys = {};
|
||||
}
|
||||
|
||||
public setKeys(keys: Record<string, ICrossSigningKey>): void {
|
||||
const signingKeys: Record<string, ICrossSigningKey> = {};
|
||||
public setKeys(keys: Record<string, CrossSigningKeyInfo>): void {
|
||||
const signingKeys: Record<string, CrossSigningKeyInfo> = {};
|
||||
if (keys.master) {
|
||||
if (keys.master.user_id !== this.userId) {
|
||||
const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId;
|
||||
@@ -457,7 +457,7 @@ export class CrossSigningInfo {
|
||||
}
|
||||
}
|
||||
|
||||
public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey | undefined> {
|
||||
public async signUser(key: CrossSigningInfo): Promise<CrossSigningKeyInfo | undefined> {
|
||||
if (!this.keys.user_signing) {
|
||||
logger.info("No user signing key: not signing user");
|
||||
return;
|
||||
|
||||
@@ -20,22 +20,15 @@ import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { Method, ClientPrefix } from "../http-api";
|
||||
import { Crypto, ICryptoCallbacks } from "./index";
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
CrossSigningKeys,
|
||||
ICrossSigningKey,
|
||||
ISignedKey,
|
||||
KeySignatures,
|
||||
} from "../client";
|
||||
import { ClientEvent, ClientEventHandlerMap, CrossSigningKeys, ISignedKey, KeySignatures } from "../client";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo } from "../crypto-api";
|
||||
|
||||
interface ICrossSigningKeys {
|
||||
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
|
||||
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
|
||||
keys: Record<"master" | "self_signing" | "user_signing", CrossSigningKeyInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -871,6 +871,9 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
|
||||
[Aes256.algorithmName]: Aes256,
|
||||
};
|
||||
|
||||
// the linter doesn't like this but knip does
|
||||
// eslint-disable-next-line tsdoc/syntax
|
||||
/** @alias */
|
||||
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,9 @@ limitations under the License.
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
export let crypto = globalThis.window?.crypto;
|
||||
export let subtleCrypto = globalThis.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
|
||||
export let TextEncoder = globalThis.window?.TextEncoder;
|
||||
export let crypto = globalThis.crypto;
|
||||
export let subtleCrypto = crypto?.subtle ?? crypto?.webkitSubtle; // TODO: Stop using webkitSubtle fallback
|
||||
export let TextEncoder = globalThis.TextEncoder;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
if (!crypto) {
|
||||
|
||||
+39
-26
@@ -56,14 +56,7 @@ import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { ToDeviceBatch } from "../models/ToDeviceMessage";
|
||||
import {
|
||||
ClientEvent,
|
||||
ICrossSigningKey,
|
||||
IKeysUploadResponse,
|
||||
ISignedKey,
|
||||
IUploadKeySignaturesResponse,
|
||||
MatrixClient,
|
||||
} from "../client";
|
||||
import { ClientEvent, IKeysUploadResponse, ISignedKey, IUploadKeySignaturesResponse, MatrixClient } from "../client";
|
||||
import { IRoomEncryption, RoomList } from "./RoomList";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { ISyncStateData } from "../sync";
|
||||
@@ -89,6 +82,7 @@ import { ISecretRequest } from "./SecretSharing";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningKeyInfo,
|
||||
CrossSigningStatus,
|
||||
DecryptionFailureCode,
|
||||
DeviceVerificationStatus,
|
||||
@@ -649,7 +643,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getVersion}.
|
||||
* Implementation of {@link Crypto.CryptoApi#getVersion}.
|
||||
*/
|
||||
public getVersion(): string {
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
@@ -809,7 +803,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningStatus}
|
||||
* Implementation of {@link Crypto.CryptoApi#getCrossSigningStatus}
|
||||
*/
|
||||
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
|
||||
const publicKeysOnDevice = Boolean(this.crossSigningInfo.getId());
|
||||
@@ -1173,7 +1167,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#resetKeyBackup}.
|
||||
* Implementation of {@link Crypto.CryptoApi#resetKeyBackup}.
|
||||
*/
|
||||
public async resetKeyBackup(): Promise<void> {
|
||||
// Delete existing ones
|
||||
@@ -1209,7 +1203,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#deleteKeyBackupVersion}.
|
||||
* Implementation of {@link Crypto.CryptoApi#deleteKeyBackupVersion}.
|
||||
*/
|
||||
public async deleteKeyBackupVersion(version: string): Promise<void> {
|
||||
await this.backupManager.deleteKeyBackupVersion(version);
|
||||
@@ -1356,7 +1350,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Get the current status of key backup.
|
||||
*
|
||||
* Implementation of {@link CryptoApi.getActiveSessionBackupVersion}.
|
||||
* Implementation of {@link Crypto.CryptoApi.getActiveSessionBackupVersion}.
|
||||
*/
|
||||
public async getActiveSessionBackupVersion(): Promise<string | null> {
|
||||
if (this.backupManager.getKeyBackupEnabled()) {
|
||||
@@ -1378,7 +1372,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Force a re-check of the key backup and enable/disable it as appropriate.
|
||||
*
|
||||
* Implementation of {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
* Implementation of {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public async checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null> {
|
||||
const checkResult = await this.backupManager.checkKeyBackup();
|
||||
@@ -1527,7 +1521,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
private async checkForValidDeviceSignature(
|
||||
userId: string,
|
||||
key: ICrossSigningKey,
|
||||
key: CrossSigningKeyInfo,
|
||||
devices: Record<string, IDevice>,
|
||||
): Promise<string[]> {
|
||||
const deviceIds: string[] = [];
|
||||
@@ -1595,7 +1589,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getUserVerificationStatus}.
|
||||
* Implementation of {@link Crypto.CryptoApi.getUserVerificationStatus}.
|
||||
*/
|
||||
public async getUserVerificationStatus(userId: string): Promise<UserTrustLevel> {
|
||||
return this.checkUserTrust(userId);
|
||||
@@ -1894,7 +1888,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public importBackedUpRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise<void> {
|
||||
public importBackedUpRoomKeys(
|
||||
keys: IMegolmSessionData[],
|
||||
backupVersion: string,
|
||||
opts: ImportRoomKeysOpts = {},
|
||||
): Promise<void> {
|
||||
opts.source = "backup";
|
||||
return this.importRoomKeys(keys, opts);
|
||||
}
|
||||
@@ -1904,7 +1902,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param keys - The new trusted set of keys
|
||||
*/
|
||||
private async storeTrustedSelfKeys(keys: Record<string, ICrossSigningKey> | null): Promise<void> {
|
||||
private async storeTrustedSelfKeys(keys: Record<string, CrossSigningKeyInfo> | null): Promise<void> {
|
||||
if (keys) {
|
||||
this.crossSigningInfo.setKeys(keys);
|
||||
} else {
|
||||
@@ -1992,7 +1990,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns base64-encoded ed25519 key.
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}.
|
||||
* @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
public getDeviceEd25519Key(): string | null {
|
||||
return this.olmDevice.deviceEd25519Key;
|
||||
@@ -2003,14 +2001,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns base64-encoded curve25519 key.
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}
|
||||
* @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys}
|
||||
*/
|
||||
public getDeviceCurve25519Key(): string | null {
|
||||
return this.olmDevice.deviceCurve25519Key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
||||
* Implementation of {@link Crypto.CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
public async getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
|
||||
if (!this.olmDevice.deviceCurve25519Key) {
|
||||
@@ -2346,7 +2344,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Mark the given device as locally verified.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#setDeviceVerified}.
|
||||
* Implementation of {@link Crypto.CryptoApi#setDeviceVerified}.
|
||||
*/
|
||||
public async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
|
||||
await this.setDeviceVerification(userId, deviceId, verified);
|
||||
@@ -2355,7 +2353,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Blindly cross-sign one of our other devices.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#crossSignDevice}.
|
||||
* Implementation of {@link Crypto.CryptoApi#crossSignDevice}.
|
||||
*/
|
||||
public async crossSignDevice(deviceId: string): Promise<void> {
|
||||
await this.setDeviceVerified(this.userId, deviceId, true);
|
||||
@@ -2390,7 +2388,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
blocked: boolean | null = null,
|
||||
known: boolean | null = null,
|
||||
keys?: Record<string, string>,
|
||||
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
|
||||
): Promise<DeviceInfo | CrossSigningInfo | CrossSigningKeyInfo | undefined> {
|
||||
// Check if the 'device' is actually a cross signing key
|
||||
// The js-sdk's verification treats cross-signing keys as devices
|
||||
// and so uses this method to mark them verified.
|
||||
@@ -2791,7 +2789,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
|
||||
* Implementation of {@link Crypto.CryptoApi.getEncryptionInfoForEvent}.
|
||||
*/
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
const encryptionInfo = this.getEventEncryptionInfo(event);
|
||||
@@ -4274,7 +4272,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isEncryptionEnabledInRoom}.
|
||||
* Implementation of {@link Crypto.CryptoApi#isEncryptionEnabledInRoom}.
|
||||
*/
|
||||
public async isEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
|
||||
return this.isRoomEncrypted(roomId);
|
||||
@@ -4287,6 +4285,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
||||
return this.roomList.getRoomEncryption(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether dehydrated devices are supported by the crypto backend
|
||||
* and by the server.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub function -- dehydration is not implemented here, so throw error
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
|
||||
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
|
||||
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { TrackingStatus } from "../DeviceList";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
@@ -27,6 +26,7 @@ import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { DehydrationManager } from "../dehydration";
|
||||
import { IEncryptedPayload } from "../aes";
|
||||
import { CrossSigningKeyInfo } from "../../crypto-api";
|
||||
|
||||
/**
|
||||
* Internal module. Definitions for storage for the crypto module
|
||||
@@ -100,13 +100,13 @@ export interface CryptoStore {
|
||||
// Olm Account
|
||||
getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
|
||||
storeAccount(txn: unknown, accountPickle: string): void;
|
||||
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void;
|
||||
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void;
|
||||
getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void;
|
||||
storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void;
|
||||
storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void;
|
||||
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
type: K,
|
||||
|
||||
@@ -33,11 +33,11 @@ import {
|
||||
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
|
||||
} from "./base";
|
||||
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store";
|
||||
import { CrossSigningKeyInfo } from "../../crypto-api";
|
||||
|
||||
const PROFILE_TRANSACTIONS = false;
|
||||
|
||||
@@ -418,7 +418,7 @@ export class Backend implements CryptoStore {
|
||||
|
||||
public getCrossSigningKeys(
|
||||
txn: IDBTransaction,
|
||||
func: (keys: Record<string, ICrossSigningKey> | null) => void,
|
||||
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
const getReq = objectStore.get("crossSigningKeys");
|
||||
@@ -447,7 +447,7 @@ export class Backend implements CryptoStore {
|
||||
};
|
||||
}
|
||||
|
||||
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
|
||||
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
objectStore.put(keys, "crossSigningKeys");
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ import {
|
||||
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
|
||||
} from "./base";
|
||||
import { IRoomKeyRequestBody } from "../index";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { CrossSigningKeyInfo } from "../../crypto-api";
|
||||
|
||||
/*
|
||||
* Internal module. indexeddb storage for e2e.
|
||||
@@ -420,7 +420,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
*/
|
||||
public getCrossSigningKeys(
|
||||
txn: IDBTransaction,
|
||||
func: (keys: Record<string, ICrossSigningKey> | null) => void,
|
||||
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
|
||||
): void {
|
||||
this.backend!.getCrossSigningKeys(txn, func);
|
||||
}
|
||||
@@ -444,7 +444,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param keys - keys object as getCrossSigningKeys()
|
||||
*/
|
||||
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
|
||||
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
|
||||
this.backend!.storeCrossSigningKeys(txn, keys);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ import {
|
||||
} from "./base";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { safeSet } from "../../utils";
|
||||
import { CrossSigningKeyInfo } from "../../crypto-api";
|
||||
|
||||
/**
|
||||
* Internal module. Partial localStorage backed storage for e2e.
|
||||
@@ -531,8 +531,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
|
||||
setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
|
||||
}
|
||||
|
||||
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
|
||||
const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS);
|
||||
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
|
||||
const keys = getJsonItem<Record<string, CrossSigningKeyInfo>>(this.store, KEY_CROSS_SIGNING_KEYS);
|
||||
func(keys);
|
||||
}
|
||||
|
||||
@@ -545,7 +545,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
|
||||
func(key);
|
||||
}
|
||||
|
||||
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
|
||||
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
|
||||
setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,21 @@ import {
|
||||
SESSION_BATCH_SIZE,
|
||||
} from "./base";
|
||||
import { IRoomKeyRequestBody } from "../index";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { CrossSigningKeyInfo } from "../../crypto-api";
|
||||
|
||||
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
|
||||
return encodeURIComponent(senderCurve25519Key) + "/" + encodeURIComponent(sessionId);
|
||||
}
|
||||
|
||||
function decodeSessionKey(key: string): { senderKey: string; sessionId: string } {
|
||||
const keyParts = key.split("/");
|
||||
const senderKey = decodeURIComponent(keyParts[0]);
|
||||
const sessionId = decodeURIComponent(keyParts[1]);
|
||||
return { senderKey, sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal module. in-memory storage for e2e.
|
||||
@@ -45,7 +56,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
private migrationState: MigrationState = MigrationState.NOT_STARTED;
|
||||
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
|
||||
private account: string | null = null;
|
||||
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
|
||||
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
|
||||
private privateKeys: Partial<SecretStorePrivateKeys> = {};
|
||||
|
||||
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
|
||||
@@ -308,7 +319,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
this.account = accountPickle;
|
||||
}
|
||||
|
||||
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
|
||||
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
|
||||
func(this.crossSigningKeys);
|
||||
}
|
||||
|
||||
@@ -321,7 +332,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
func(result || null);
|
||||
}
|
||||
|
||||
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
|
||||
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
|
||||
this.crossSigningKeys = keys;
|
||||
}
|
||||
|
||||
@@ -481,20 +492,14 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
txn: unknown,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): void {
|
||||
const k = senderCurve25519Key + "/" + sessionId;
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
|
||||
}
|
||||
|
||||
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
|
||||
for (const key of Object.keys(this.inboundGroupSessions)) {
|
||||
// we can't use split, as the components we are trying to split out
|
||||
// might themselves contain '/' characters. We rely on the
|
||||
// senderKey being a (32-byte) curve25519 key, base64-encoded
|
||||
// (hence 43 characters long).
|
||||
|
||||
func({
|
||||
senderKey: key.slice(0, 43),
|
||||
sessionId: key.slice(44),
|
||||
...decodeSessionKey(key),
|
||||
sessionData: this.inboundGroupSessions[key],
|
||||
});
|
||||
}
|
||||
@@ -507,7 +512,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: unknown,
|
||||
): void {
|
||||
const k = senderCurve25519Key + "/" + sessionId;
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
if (this.inboundGroupSessions[k] === undefined) {
|
||||
this.inboundGroupSessions[k] = sessionData;
|
||||
}
|
||||
@@ -519,7 +524,8 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: unknown,
|
||||
): void {
|
||||
this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
this.inboundGroupSessions[k] = sessionData;
|
||||
}
|
||||
|
||||
public storeEndToEndInboundGroupSessionWithheld(
|
||||
@@ -528,7 +534,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
sessionData: IWithheld,
|
||||
txn: unknown,
|
||||
): void {
|
||||
const k = senderCurve25519Key + "/" + sessionId;
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
this.inboundGroupSessionsWithheld[k] = sessionData;
|
||||
}
|
||||
|
||||
@@ -554,8 +560,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
const result: SessionExtended[] = [];
|
||||
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
|
||||
result.push({
|
||||
senderKey: key.slice(0, 43),
|
||||
sessionId: key.slice(44),
|
||||
...decodeSessionKey(key),
|
||||
sessionData: session,
|
||||
needsBackup: key in this.sessionsNeedingBackup,
|
||||
});
|
||||
@@ -584,7 +589,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
for (const { senderKey, sessionId } of sessions) {
|
||||
const k = senderKey + "/" + sessionId;
|
||||
const k = encodeSessionKey(senderKey, sessionId);
|
||||
delete this.inboundGroupSessions[k];
|
||||
}
|
||||
}
|
||||
@@ -614,8 +619,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
for (const session in this.sessionsNeedingBackup) {
|
||||
if (this.inboundGroupSessions[session]) {
|
||||
sessions.push({
|
||||
senderKey: session.slice(0, 43),
|
||||
sessionId: session.slice(44),
|
||||
...decodeSessionKey(session),
|
||||
sessionData: this.inboundGroupSessions[session],
|
||||
});
|
||||
if (limit && session.length >= limit) {
|
||||
@@ -632,7 +636,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
|
||||
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
|
||||
for (const session of sessions) {
|
||||
const sessionKey = session.senderKey + "/" + session.sessionId;
|
||||
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
|
||||
delete this.sessionsNeedingBackup[sessionKey];
|
||||
}
|
||||
return Promise.resolve();
|
||||
@@ -640,7 +644,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
|
||||
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
|
||||
for (const session of sessions) {
|
||||
const sessionKey = session.senderKey + "/" + session.sessionId;
|
||||
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
|
||||
this.sessionsNeedingBackup[sessionKey] = true;
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ReciprocateQRCode extends Base {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.reciprocateQREvent = {
|
||||
confirm: resolve,
|
||||
cancel: () => reject(newUserCancelledError()),
|
||||
cancel: (): void => reject(newUserCancelledError()),
|
||||
};
|
||||
this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
|
||||
});
|
||||
|
||||
@@ -308,8 +308,8 @@ export class SAS extends Base {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
cancel: () => reject(newUserCancelledError()),
|
||||
mismatch: () => reject(newMismatchedSASError()),
|
||||
cancel: (): void => reject(newUserCancelledError()),
|
||||
mismatch: (): void => reject(newMismatchedSASError()),
|
||||
};
|
||||
this.emit(SasEvent.ShowSas, this.sasEvent);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum Feature {
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
RelationsRecursion = "RelationsRecursion",
|
||||
IntentionalMentions = "IntentionalMentions",
|
||||
MigrateServerNameToVia = "MigrateServerNameToVia",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -65,6 +66,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
|
||||
matrixVersion: "v1.7",
|
||||
},
|
||||
[Feature.MigrateServerNameToVia]: {
|
||||
unstablePrefixes: ["org.matrix.msc4156"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -19,4 +19,7 @@ export enum Method {
|
||||
Put = "PUT",
|
||||
Post = "POST",
|
||||
Delete = "DELETE",
|
||||
Options = "OPTIONS",
|
||||
Head = "HEAD",
|
||||
Patch = "PATCH",
|
||||
}
|
||||
|
||||
+4
-10
@@ -121,10 +121,6 @@ interface ThreepidCreds {
|
||||
type EmailIdentityDict = {
|
||||
type: AuthType.Email;
|
||||
threepid_creds: ThreepidCreds;
|
||||
/**
|
||||
* @deprecated in favour of `threepid_creds` - kept for backwards compatibility
|
||||
*/
|
||||
threepidCreds?: ThreepidCreds;
|
||||
session: string;
|
||||
};
|
||||
|
||||
@@ -343,15 +339,13 @@ export class InteractiveAuth<T> {
|
||||
sid: this.emailSid,
|
||||
client_secret: this.clientSecret,
|
||||
};
|
||||
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
const isUrl = this.matrixClient.getIdentityServerUrl();
|
||||
if (isUrl) {
|
||||
creds.id_server = new URL(isUrl).host;
|
||||
}
|
||||
authDict = {
|
||||
type: EMAIL_STAGE_TYPE,
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/matrix-org/synapse/issues/5665
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds: creds,
|
||||
threepidCreds: creds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-14
@@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
|
||||
import { CryptoStore } from "./crypto/store/base";
|
||||
|
||||
export * from "./client";
|
||||
export * from "./serverCapabilities";
|
||||
export * from "./embedded";
|
||||
export * from "./http-api";
|
||||
export * from "./autodiscovery";
|
||||
@@ -77,6 +78,7 @@ export * from "./@types/extensible_events";
|
||||
export * from "./@types/IIdentityServerProvider";
|
||||
export * from "./models/room-summary";
|
||||
export * from "./models/event-status";
|
||||
export type { RoomSummary } from "./client";
|
||||
export * as ContentHelpers from "./content-helpers";
|
||||
export * as SecretStorage from "./secret-storage";
|
||||
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
|
||||
@@ -113,20 +115,6 @@ export { LocationAssetType } from "./@types/location";
|
||||
*/
|
||||
export * as Crypto from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.CryptoApi}
|
||||
*/
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.DeviceVerificationStatus}
|
||||
*/
|
||||
export { DeviceVerificationStatus } from "./crypto-api";
|
||||
|
||||
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
|
||||
|
||||
/**
|
||||
|
||||
+136
-47
@@ -14,52 +14,114 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EitherAnd } from "matrix-events-sdk/lib/types";
|
||||
|
||||
import { MatrixEvent } from "../matrix";
|
||||
import { deepCompare } from "../utils";
|
||||
import { Focus } from "./focus";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus";
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
|
||||
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
|
||||
export interface CallMembershipData {
|
||||
application?: string;
|
||||
|
||||
// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
|
||||
|
||||
// MSC4143 (MatrixRTC) session membership data
|
||||
|
||||
export type SessionMembershipData = {
|
||||
application: string;
|
||||
call_id: string;
|
||||
device_id: string;
|
||||
|
||||
focus_active: Focus;
|
||||
foci_preferred: Focus[];
|
||||
created_ts?: number;
|
||||
|
||||
// Application specific data
|
||||
scope?: CallScope;
|
||||
};
|
||||
|
||||
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
|
||||
"focus_active" in data;
|
||||
|
||||
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = "Malformed session membership event: ";
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
|
||||
// optional parameters
|
||||
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
|
||||
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// Legacy session membership data
|
||||
|
||||
export type CallMembershipDataLegacy = {
|
||||
application: string;
|
||||
call_id: string;
|
||||
scope: CallScope;
|
||||
device_id: string;
|
||||
created_ts?: number;
|
||||
expires?: number;
|
||||
expires_ts?: number;
|
||||
foci_active?: Focus[];
|
||||
membershipID: string;
|
||||
}
|
||||
created_ts?: number;
|
||||
foci_active?: Focus[];
|
||||
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
|
||||
|
||||
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
|
||||
"membershipID" in data;
|
||||
|
||||
const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => {
|
||||
const prefix = "Malformed legacy rtc membership event: ";
|
||||
if (!("expires" in data || "expires_ts" in data)) {
|
||||
errors.push(prefix + "expires_ts or expires must be present");
|
||||
}
|
||||
if ("expires" in data) {
|
||||
if (typeof data.expires !== "number") {
|
||||
errors.push(prefix + "expires must be numeric");
|
||||
}
|
||||
}
|
||||
if ("expires_ts" in data) {
|
||||
if (typeof data.expires_ts !== "number") {
|
||||
errors.push(prefix + "expires_ts must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string");
|
||||
// optional elements
|
||||
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
|
||||
|
||||
export class CallMembership {
|
||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||
return deepCompare(a.data, b.data);
|
||||
return deepCompare(a.membershipData, b.membershipData);
|
||||
}
|
||||
private membershipData: CallMembershipData;
|
||||
|
||||
public constructor(
|
||||
private parentEvent: MatrixEvent,
|
||||
private data: CallMembershipData,
|
||||
data: any,
|
||||
) {
|
||||
if (!(data.expires || data.expires_ts)) {
|
||||
throw new Error("Malformed membership: expires_ts or expires must be present");
|
||||
const sessionErrors: string[] = [];
|
||||
const legacyErrors: string[] = [];
|
||||
if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) {
|
||||
throw Error(
|
||||
`unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`,
|
||||
);
|
||||
} else {
|
||||
this.membershipData = data;
|
||||
}
|
||||
if (data.expires) {
|
||||
if (typeof data.expires !== "number") {
|
||||
throw new Error("Malformed membership: expires must be numeric");
|
||||
}
|
||||
}
|
||||
if (data.expires_ts) {
|
||||
if (typeof data.expires_ts !== "number") {
|
||||
throw new Error("Malformed membership: expires_ts must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
|
||||
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
|
||||
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
|
||||
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
|
||||
}
|
||||
|
||||
public get sender(): string | undefined {
|
||||
@@ -67,62 +129,89 @@ export class CallMembership {
|
||||
}
|
||||
|
||||
public get callId(): string {
|
||||
return this.data.call_id;
|
||||
return this.membershipData.call_id;
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
return this.data.device_id;
|
||||
return this.membershipData.device_id;
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.data.application;
|
||||
return this.membershipData.application;
|
||||
}
|
||||
|
||||
public get scope(): CallScope {
|
||||
return this.data.scope;
|
||||
public get scope(): CallScope | undefined {
|
||||
return this.membershipData.scope;
|
||||
}
|
||||
|
||||
public get membershipID(): string {
|
||||
return this.data.membershipID;
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID;
|
||||
// the createdTs behaves equivalent to the membershipID.
|
||||
// we only need the field for the legacy member envents where we needed to update them
|
||||
// synapse ignores sending state events if they have the same content.
|
||||
else return this.createdTs().toString();
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
return this.data.created_ts ?? this.parentEvent.getTs();
|
||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
||||
}
|
||||
|
||||
public getAbsoluteExpiry(): number {
|
||||
if (this.data.expires) {
|
||||
return this.createdTs() + this.data.expires;
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
return this.createdTs() + this.membershipData.expires;
|
||||
} else {
|
||||
// We know it exists because we checked for this in the constructor.
|
||||
return this.data.expires_ts!;
|
||||
return this.membershipData.expires_ts;
|
||||
}
|
||||
}
|
||||
|
||||
// gets the expiry time of the event, converted into the device's local time
|
||||
public getLocalExpiry(): number {
|
||||
if (this.data.expires) {
|
||||
public getLocalExpiry(): number | undefined {
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
|
||||
|
||||
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
|
||||
|
||||
return localCreationTs + this.data.expires;
|
||||
return localCreationTs + this.membershipData.expires;
|
||||
} else {
|
||||
// With expires_ts we cannot convert to local time.
|
||||
// TODO: Check the server timestamp and compute a diff to local time.
|
||||
return this.data.expires_ts!;
|
||||
return this.membershipData.expires_ts;
|
||||
}
|
||||
}
|
||||
|
||||
public getMsUntilExpiry(): number {
|
||||
return this.getLocalExpiry() - Date.now();
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
|
||||
}
|
||||
|
||||
public isExpired(): boolean {
|
||||
return this.getMsUntilExpiry() <= 0;
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
|
||||
|
||||
// MSC4143 events expire by being updated. So if the event exists, its not expired.
|
||||
return false;
|
||||
}
|
||||
|
||||
public getActiveFoci(): Focus[] {
|
||||
return this.data.foci_active ?? [];
|
||||
public getPreferredFoci(): Focus[] {
|
||||
// To support both, the new and the old MatrixRTC memberships have two cases based
|
||||
// on the availablitiy of `foci_preferred`
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? [];
|
||||
|
||||
// MSC4143 style membership
|
||||
return this.membershipData.foci_preferred;
|
||||
}
|
||||
|
||||
public getFocusSelection(): string | undefined {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) {
|
||||
return "oldest_membership";
|
||||
} else {
|
||||
const focusActive = this.membershipData.focus_active;
|
||||
if (isLivekitFocusActive(focusActive)) {
|
||||
return focusActive.focus_selection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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 { Focus } from "./focus";
|
||||
|
||||
export interface LivekitFocusConfig extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitFocus extends LivekitFocusConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
||||
|
||||
export interface LivekitFocusActive extends Focus {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership";
|
||||
}
|
||||
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
@@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline";
|
||||
import { Room } from "../models/room";
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType } from "../@types/event";
|
||||
import { CallMembership, CallMembershipData } from "./CallMembership";
|
||||
import {
|
||||
CallMembership,
|
||||
CallMembershipData,
|
||||
CallMembershipDataLegacy,
|
||||
SessionMembershipData,
|
||||
isLegacyCallMembershipData,
|
||||
} from "./CallMembership";
|
||||
import { RoomStateEvent } from "../models/room-state";
|
||||
import { Focus } from "./focus";
|
||||
import { randomString, secureRandomBase64Url } from "../randomstring";
|
||||
@@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import { MatrixError } from "../http-api/errors";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus";
|
||||
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall";
|
||||
|
||||
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
|
||||
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
|
||||
@@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent {
|
||||
MembershipsChanged = "memberships_changed",
|
||||
// We joined or left the session: our own local idea of whether we are joined,
|
||||
// separate from MembershipsChanged, ie. independent of whether our member event
|
||||
// has succesfully gone through.
|
||||
// has successfully gone through.
|
||||
JoinStateChanged = "join_state_changed",
|
||||
// The key used to encrypt media has changed
|
||||
EncryptionKeyChanged = "encryption_key_changed",
|
||||
@@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
participantId: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export interface JoinSessionConfig {
|
||||
/** If true, generate and share a media key for this participant,
|
||||
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
|
||||
* media keys for other participants become available.
|
||||
*/
|
||||
manageMediaKeys?: boolean;
|
||||
/** Lets you configure how the events for the session are formatted.
|
||||
* - legacy: use one event with a membership array.
|
||||
* - MSC4143: use one event per membership (with only one membership per event)
|
||||
* More details can be found in MSC4143 and by checking the types:
|
||||
* `CallMembershipDataLegacy` and `SessionMembershipData`
|
||||
*/
|
||||
useLegacyMemberEvents?: boolean;
|
||||
}
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
@@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
|
||||
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
private activeFoci: Focus[] | undefined;
|
||||
// This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
|
||||
private ownFocusActive?: Focus;
|
||||
// This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
|
||||
private ownFociPreferred?: Focus[];
|
||||
|
||||
private updateCallMembershipRunning = false;
|
||||
private needCallMembershipUpdate = false;
|
||||
|
||||
private manageMediaKeys = false;
|
||||
private useLegacyMemberEvents = true;
|
||||
// userId:deviceId => array of keys
|
||||
private encryptionKeys = new Map<string, Array<Uint8Array>>();
|
||||
private lastEncryptionKeyUpdateRequest?: number;
|
||||
@@ -134,22 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
|
||||
if (eventMemberships === undefined) {
|
||||
logger.debug(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
|
||||
continue;
|
||||
const content = memberEvent.getContent();
|
||||
let membershipContents: any[] = [];
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if ("memberships" in content) {
|
||||
// we have a legacy (one event for all devices) event
|
||||
if (!Array.isArray(content["memberships"])) {
|
||||
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
|
||||
continue;
|
||||
}
|
||||
membershipContents = content["memberships"];
|
||||
} else {
|
||||
// We have a MSC4143 event membership event
|
||||
if (Object.keys(content).length !== 0) {
|
||||
// We checked for empty content to not try to construct CallMembership's with {}.
|
||||
membershipContents.push(content);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(eventMemberships)) {
|
||||
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
|
||||
if (membershipContents.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const membershipData of eventMemberships) {
|
||||
for (const membershipData of membershipContents) {
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (membership.callId !== "" || membership.scope !== "m.room") {
|
||||
// for now, just ignore anything that isn't the a room scope call
|
||||
// for now, just ignore anything that isn't a room scope call
|
||||
logger.info(`Ignoring user-scoped call`);
|
||||
continue;
|
||||
}
|
||||
@@ -203,6 +239,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
*/
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
@@ -233,30 +270,34 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* desired.
|
||||
* This method will return immediately and the session will be joined in the background.
|
||||
*
|
||||
* @param activeFoci - The list of foci to set as currently active in the call member event
|
||||
* @param manageMediaKeys - If true, generate and share a a media key for this participant,
|
||||
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
|
||||
* media keys for other participants become available.
|
||||
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
|
||||
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
|
||||
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
|
||||
* or optionally other room members homeserver well known.
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(activeFoci: Focus[], manageMediaKeys?: boolean): void {
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${manageMediaKeys}`);
|
||||
this.activeFoci = activeFoci;
|
||||
this.ownFocusActive = fociActive;
|
||||
this.ownFociPreferred = fociPreferred;
|
||||
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
|
||||
this.manageMediaKeys = manageMediaKeys ?? false;
|
||||
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
this.useLegacyMemberEvents = joinConfig?.useLegacyMemberEvents ?? this.useLegacyMemberEvents;
|
||||
this.membershipId = randomString(5);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
if (manageMediaKeys) {
|
||||
|
||||
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${this.manageMediaKeys}`);
|
||||
if (joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestKeyEventSend();
|
||||
}
|
||||
// We don't wait for this, mostly because it may fail and schedule a retry, so this
|
||||
// function returning doesn't really mean anything at all.
|
||||
this.triggerCallMembershipEventUpdate();
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
logger.info(`Leaving call session in room ${this.room.roomId}`);
|
||||
this.relativeExpiry = undefined;
|
||||
this.activeFoci = undefined;
|
||||
this.ownFocusActive = undefined;
|
||||
this.manageMediaKeys = false;
|
||||
this.membershipId = undefined;
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
@@ -316,6 +357,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
});
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.ownFocusActive && isLivekitFocusActive(this.ownFocusActive)) {
|
||||
// A livekit active focus
|
||||
if (this.ownFocusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
if (!this.ownFocusActive) {
|
||||
// we use the legacy call.member events so default to oldest member
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
|
||||
return this.encryptionKeys.get(getParticipantId(userId, deviceId));
|
||||
}
|
||||
@@ -345,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* @param userId - The user ID of the participant
|
||||
* @param deviceId - Device ID of the participant
|
||||
* @param encryptionKeyIndex - The index of the key to set
|
||||
* @param encryptionKeyString - The string represenation of the key to set in base64
|
||||
* @param encryptionKeyString - The string representation of the key to set in base64
|
||||
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
|
||||
* encryption keys for the local participant to allow time for the key to
|
||||
* be distributed.
|
||||
@@ -380,7 +436,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
/**
|
||||
* Generate a new sender key and add it at the next available index
|
||||
* @param delayBeforeUse - If true, wait for a short period before settign the key for the
|
||||
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
|
||||
* media encryptor to use. If false, set the key immediately.
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): void {
|
||||
@@ -489,7 +545,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
let soonestExpiry;
|
||||
for (const membership of this.memberships) {
|
||||
const thisExpiry = membership.getMsUntilExpiry();
|
||||
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
|
||||
// If getMsUntilExpiry is undefined we have a MSC4143 (MatrixRTC) compliant event - it never expires
|
||||
// but will be reliably resent on disconnect.
|
||||
if (thisExpiry !== undefined && (soonestExpiry === undefined || thisExpiry < soonestExpiry)) {
|
||||
soonestExpiry = thisExpiry;
|
||||
}
|
||||
}
|
||||
@@ -503,6 +561,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
return this.memberships[0];
|
||||
}
|
||||
|
||||
public getFocusInUse(): Focus | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
|
||||
return oldestMembership.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
public onCallEncryption = (event: MatrixEvent): void => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
@@ -614,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* Constructs our own membership
|
||||
* @param prevMembership - The previous value of our call membership, if any
|
||||
*/
|
||||
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
|
||||
private makeMyMembershipLegacy(deviceId: string, prevMembership?: CallMembership): CallMembershipDataLegacy {
|
||||
if (this.relativeExpiry === undefined) {
|
||||
throw new Error("Tried to create our own membership event when we're not joined!");
|
||||
}
|
||||
if (this.membershipId === undefined) {
|
||||
throw new Error("Tried to create our own membership event when we have no membership ID!");
|
||||
}
|
||||
|
||||
const m: CallMembershipData = {
|
||||
const createdTs = prevMembership?.createdTs();
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.client.getDeviceId()!,
|
||||
device_id: deviceId,
|
||||
expires: this.relativeExpiry,
|
||||
foci_active: this.activeFoci,
|
||||
// TODO: Date.now() should be the origin_server_ts (now).
|
||||
expires_ts: this.relativeExpiry + (createdTs ?? Date.now()),
|
||||
// we use the fociPreferred since this is the list of foci.
|
||||
// it is named wrong in the Legacy events.
|
||||
foci_active: this.ownFociPreferred,
|
||||
membershipID: this.membershipId,
|
||||
...(createdTs ? { created_ts: createdTs } : {}),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(deviceId: string): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: deviceId,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.ownFociPreferred ?? [],
|
||||
};
|
||||
|
||||
if (prevMembership) m.created_ts = prevMembership.createdTs();
|
||||
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
|
||||
// TODO: Date.now() should be the origin_server_ts (now).
|
||||
else m.expires_ts = Date.now() + (m.expires ?? 0);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -647,36 +723,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
myPrevMembershipData?: CallMembershipData,
|
||||
myPrevMembership?: CallMembership,
|
||||
): boolean {
|
||||
// work out if we need to update our membership event
|
||||
let needsUpdate = false;
|
||||
if (myPrevMembership && myPrevMembership.getMsUntilExpiry() === undefined) return false;
|
||||
|
||||
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
|
||||
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
|
||||
if (this.isJoined()) {
|
||||
// ...or if we are joined, but there's no valid membership event
|
||||
if (!myPrevMembership) {
|
||||
needsUpdate = true;
|
||||
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
|
||||
// ...or if the expiry time needs bumping
|
||||
needsUpdate = true;
|
||||
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
|
||||
}
|
||||
if (!this.isJoined()) return !!myPrevMembershipData;
|
||||
|
||||
// ...or if we are joined, but there's no valid membership event
|
||||
if (!myPrevMembership) return true;
|
||||
|
||||
const expiryTime = myPrevMembership.getMsUntilExpiry();
|
||||
if (expiryTime !== undefined && expiryTime < MEMBERSHIP_EXPIRY_TIME / 2) {
|
||||
// ...or if the expiry time needs bumping
|
||||
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
|
||||
return true;
|
||||
}
|
||||
|
||||
return needsUpdate;
|
||||
return false;
|
||||
}
|
||||
|
||||
private makeNewMembership(deviceId: string): SessionMembershipData | {} {
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
return this.makeMyMembership(deviceId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
/**
|
||||
* Makes a new membership list given the old list alonng with this user's previous membership event
|
||||
* (if any) and this device's previous membership (if any)
|
||||
*/
|
||||
private makeNewMemberships(
|
||||
private makeNewLegacyMemberships(
|
||||
oldMemberships: CallMembershipData[],
|
||||
localDeviceId: string,
|
||||
myCallMemberEvent?: MatrixEvent,
|
||||
myPrevMembership?: CallMembership,
|
||||
): CallMembershipData[] {
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localDeviceId) throw new Error("Local device ID is null!");
|
||||
|
||||
): ExperimentalGroupCallRoomMemberState {
|
||||
const filterExpired = (m: CallMembershipData): boolean => {
|
||||
let membershipObj;
|
||||
try {
|
||||
@@ -705,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
newMemberships.push(this.makeMyMembership(myPrevMembership));
|
||||
newMemberships.push(this.makeMyMembershipLegacy(localDeviceId, myPrevMembership));
|
||||
}
|
||||
|
||||
return newMemberships;
|
||||
return { memberships: newMemberships };
|
||||
}
|
||||
|
||||
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
|
||||
@@ -742,47 +823,58 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
|
||||
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
|
||||
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
|
||||
|
||||
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
|
||||
let myPrevMembership;
|
||||
try {
|
||||
if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) {
|
||||
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
|
||||
const callMemberEvents = roomState.events.get(EventType.GroupCallMemberPrefix);
|
||||
const legacy =
|
||||
!!this.useLegacyMemberEvents ||
|
||||
(callMemberEvents?.size && this.stateEventsContainOngoingLegacySession(callMemberEvents));
|
||||
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
|
||||
if (legacy) {
|
||||
const myCallMemberEvent = callMemberEvents?.get(localUserId);
|
||||
const content = myCallMemberEvent?.getContent() ?? {};
|
||||
let myPrevMembership: CallMembership | undefined;
|
||||
// We know its CallMembershipDataLegacy
|
||||
const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"])
|
||||
? content["memberships"]
|
||||
: [];
|
||||
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
|
||||
try {
|
||||
if (
|
||||
myCallMemberEvent &&
|
||||
myPrevMembershipData &&
|
||||
isLegacyCallMembershipData(myPrevMembershipData) &&
|
||||
myPrevMembershipData.membershipID === this.membershipId
|
||||
) {
|
||||
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
|
||||
}
|
||||
} catch (e) {
|
||||
// This would indicate a bug or something weird if our own call membership
|
||||
// wasn't valid
|
||||
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
|
||||
}
|
||||
} catch (e) {
|
||||
// This would indicate a bug or something weird if our own call membership
|
||||
// wasn't valid
|
||||
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
|
||||
if (myPrevMembership) {
|
||||
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
|
||||
}
|
||||
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
|
||||
// nothing to do - reschedule the check again
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
return;
|
||||
}
|
||||
newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership);
|
||||
} else {
|
||||
newContent = this.makeNewMembership(localDeviceId);
|
||||
}
|
||||
|
||||
if (myPrevMembership) {
|
||||
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
|
||||
}
|
||||
|
||||
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
|
||||
// nothing to do - reschedule the check again
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = {
|
||||
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
newContent,
|
||||
localUserId,
|
||||
legacy ? localUserId : this.makeMembershipStateKey(localUserId, localDeviceId),
|
||||
);
|
||||
logger.info(`Sent updated call member event.`);
|
||||
|
||||
// check periodically to see if we need to refresh our member event
|
||||
if (this.isJoined()) {
|
||||
if (this.isJoined() && legacy) {
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -793,6 +885,29 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent>): boolean {
|
||||
for (const callMemberEvent of callMemberEvents.values()) {
|
||||
const content = callMemberEvent.getContent();
|
||||
if (Array.isArray(content["memberships"])) {
|
||||
for (const membership of content.memberships) {
|
||||
if (!new CallMembership(callMemberEvent, membership).isExpired()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc3779\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
private onRotateKeyTimeout = (): void => {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.removeListener(ClientEvent.Room, this.onRoom);
|
||||
this.client.removeListener(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomState);
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,4 +21,5 @@ limitations under the License.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user