Compare commits
107 Commits
v31.6.0-rc.0
...
v32.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a86d4ceb49 | |||
| 38898a60c7 | |||
| 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 | |||
| e4937e6222 | |||
| 5cdd524da7 | |||
| 0ff0093380 | |||
| b352405c89 | |||
| 0d73d0c6c7 | |||
| d2f76d4956 | |||
| c680dd7eb2 | |||
| e24bb0f50c | |||
| 1ed3b13f0d | |||
| 4f628bf64c | |||
| 7d5c003716 | |||
| dbab185f9d | |||
| cfcd191cbf | |||
| 514633c5fa | |||
| 5bffb7df4f | |||
| 9e1897dcd0 | |||
| 5f3ddc37a1 | |||
| 78a225795b | |||
| 467b49a0dc | |||
| 06e083874a | |||
| 0f25429849 | |||
| 32ddf2813d | |||
| 1ed082f3d4 | |||
| 706002cdcb | |||
| 731de1108c | |||
| 2da6c0c605 | |||
| 9f1d0c3896 | |||
| 0b290fffa1 | |||
| 97844f0e47 | |||
| 85a55c79cd | |||
| 63d4195453 | |||
| d5a35f8a99 | |||
| d1259b241c | |||
| a573727662 | |||
| dce8acbf17 | |||
| 4ba1341f8f | |||
| e517d009bf | |||
| dc2d03dea5 | |||
| d5bb9e7600 | |||
| d908036f50 | |||
| afc3c6213b | |||
| 7884c22e41 | |||
| 887d8a7663 | |||
| 2c68ee2254 | |||
| d445823d0b | |||
| abe4630687 | |||
| 8664b66238 | |||
| 596826ab4d | |||
| c8ec5421c7 |
@@ -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.93.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' }}
|
||||
|
||||
@@ -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@132879b972cb7f2ac593006455875098e73cc7f2 # 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
|
||||
|
||||
@@ -61,6 +61,11 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: docs
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,99 @@
|
||||
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
|
||||
|
||||
* Remove various deprecated methods \& re-exports ([#4125](https://github.com/matrix-org/matrix-js-sdk/pull/4125)). Contributed by @t3chguy.
|
||||
* Remove the logic that throws when the lazy loading options has changed. ([#4124](https://github.com/matrix-org/matrix-js-sdk/pull/4124)). Contributed by @langleyd.
|
||||
* Fix highlights from threads disappearing on new messages ([#4106](https://github.com/matrix-org/matrix-js-sdk/pull/4106)). Contributed by @dbkr.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Add new `decryptExistingEvent` test helper ([#4133](https://github.com/matrix-org/matrix-js-sdk/pull/4133)). Contributed by @richvdh.
|
||||
* Improve types for `sendEvent` ([#4108](https://github.com/matrix-org/matrix-js-sdk/pull/4108)). Contributed by @t3chguy.
|
||||
* Remove various deprecated methods \& re-exports ([#4125](https://github.com/matrix-org/matrix-js-sdk/pull/4125)). Contributed by @t3chguy.
|
||||
* Add new enum for verification methods. ([#4129](https://github.com/matrix-org/matrix-js-sdk/pull/4129)). Contributed by @richvdh.
|
||||
* Add some test utils in a new entrypoint ([#4127](https://github.com/matrix-org/matrix-js-sdk/pull/4127)). Contributed by @richvdh.
|
||||
* Improve types for `sendStateEvent` ([#4105](https://github.com/matrix-org/matrix-js-sdk/pull/4105)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Improve types for `IPowerLevelsContent` and `hasSufficientPowerLevelFor` ([#4128](https://github.com/matrix-org/matrix-js-sdk/pull/4128)). Contributed by @galash13.
|
||||
* Remove the logic that throws when the lazy loading options has changed. ([#4124](https://github.com/matrix-org/matrix-js-sdk/pull/4124)). Contributed by @langleyd.
|
||||
* Fix highlights from threads disappearing on new messages ([#4106](https://github.com/matrix-org/matrix-js-sdk/pull/4106)). Contributed by @dbkr.
|
||||
* Extend logic for local notification processing to threads ([#4111](https://github.com/matrix-org/matrix-js-sdk/pull/4111)). Contributed by @dbkr.
|
||||
* Fix public rooms post request search params and body ([#4110](https://github.com/matrix-org/matrix-js-sdk/pull/4110)). Contributed by @ajbura.
|
||||
* Fix bugs with the first reply to a thread ([#4104](https://github.com/matrix-org/matrix-js-sdk/pull/4104)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [31.6.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.6.1) (2024-03-28)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix merging of default push rules ([#4136](https://github.com/matrix-org/matrix-js-sdk/pull/4136)).
|
||||
|
||||
|
||||
Changes in [31.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.6.0) (2024-03-26)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Introduce Membership TS type (take 2) ([#4107](https://github.com/matrix-org/matrix-js-sdk/pull/4107)). Contributed by @andybalaam.
|
||||
* fix automatic DM avatar with functional members ([#4017](https://github.com/matrix-org/matrix-js-sdk/pull/4017)). Contributed by @HarHarLinks.
|
||||
* Export types describing all specced media event formats ([#4092](https://github.com/matrix-org/matrix-js-sdk/pull/4092)). Contributed by @t3chguy.
|
||||
* Add `.m.rule.is_room_mention` push rule to DEFAULT\_OVERRIDE\_RULES ([#4100](https://github.com/matrix-org/matrix-js-sdk/pull/4100)). Contributed by @t3chguy.
|
||||
* Make sending ContentLoaded optional for a widgetClient ([#4086](https://github.com/matrix-org/matrix-js-sdk/pull/4086)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Migrate own identity local trust to rust crypto ([#4090](https://github.com/matrix-org/matrix-js-sdk/pull/4090)). Contributed by @BillCarsonFr.
|
||||
* Fix race condition with sliding sync extensions ([#4089](https://github.com/matrix-org/matrix-js-sdk/pull/4089)). Contributed by @zzorba.
|
||||
|
||||
|
||||
Changes in [31.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.5.0) (2024-03-12)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -21,15 +21,9 @@ endpoints from before Matrix 1.1, for example.
|
||||
|
||||
# Quickstart
|
||||
|
||||
## In a browser
|
||||
|
||||
### Note, the browserify build has been removed. Please use a bundler like webpack or vite instead.
|
||||
|
||||
## In Node.js
|
||||
|
||||
Ensure you have the latest LTS version of Node.js installed.
|
||||
This library relies on `fetch` which is available in Node from v18.0.0 - it should work fine also with polyfills.
|
||||
If you wish to use a ponyfill or adapter of some sort then pass it as `fetchFn` to the MatrixClient constructor options.
|
||||
> [!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.
|
||||
@@ -47,8 +41,6 @@ client.publicRooms(function (err, data) {
|
||||
See below for how to include libolm to enable end-to-end-encryption. Please check
|
||||
[the Node.js terminal app](examples/node) for a more complex example.
|
||||
|
||||
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
|
||||
|
||||
To start the client:
|
||||
|
||||
```javascript
|
||||
@@ -101,12 +93,40 @@ Object.keys(client.store.rooms).forEach((roomId) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Authenticated media
|
||||
|
||||
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) 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
|
||||
events for incoming data and state changes. Aside from wrapping the HTTP API, it:
|
||||
|
||||
- Handles syncing (via `/initialSync` and `/events`)
|
||||
- Handles syncing (via `/sync`)
|
||||
- Handles the generation of "friendly" room and member names.
|
||||
- Handles historical `RoomMember` information (e.g. display names).
|
||||
- Manages room member state across multiple events (e.g. it handles typing, power
|
||||
@@ -127,20 +147,20 @@ events for incoming data and state changes. Aside from wrapping the HTTP API, it
|
||||
- Handles room initial sync on accepting invites.
|
||||
- Handles WebRTC calling.
|
||||
|
||||
Later versions of the SDK will:
|
||||
|
||||
- Expose a `RoomSummary` which would be suitable for a recents page.
|
||||
- Provide different pluggable storage layers (e.g. local storage, database-backed)
|
||||
|
||||
# Usage
|
||||
|
||||
## Conventions
|
||||
## Supported platforms
|
||||
|
||||
### Emitted events
|
||||
`matrix-js-sdk` can be used in either Node.js applications (ensure you have the latest LTS version of Node.js installed),
|
||||
or in browser applications, via a bundler such as Webpack or Vite.
|
||||
|
||||
The SDK will emit events using an `EventEmitter`. It also
|
||||
emits object models (e.g. `Rooms`, `RoomMembers`) when they
|
||||
are updated.
|
||||
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
|
||||
|
||||
## Emitted events
|
||||
|
||||
The SDK raises notifications to the application using
|
||||
[`EventEmitter`s](https://nodejs.org/api/events.html#class-eventemitter). The `MatrixClient` itself
|
||||
implements `EventEmitter`, as do many of the high-level abstractions such as `Room` and `RoomMember`.
|
||||
|
||||
```javascript
|
||||
// Listen for low-level MatrixEvents
|
||||
@@ -161,45 +181,21 @@ client.on(RoomMemberEvent.Typing, function (event, member) {
|
||||
client.startClient();
|
||||
```
|
||||
|
||||
### Promises and Callbacks
|
||||
## Entry points
|
||||
|
||||
Most of the methods in the SDK are asynchronous: they do not directly return a
|
||||
result, but instead return a [Promise](http://documentup.com/kriskowal/q/)
|
||||
which will be fulfilled in the future.
|
||||
As well as the primary entry point (`matrix-js-sdk`), there are several other entry points which may be useful:
|
||||
|
||||
The typical usage is something like:
|
||||
|
||||
```javascript
|
||||
matrixClient.someMethod(arg1, arg2).then(function(result) {
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, if you have a Node.js-style `callback(err, result)` function,
|
||||
you can pass the result of the promise into it with something like:
|
||||
|
||||
```javascript
|
||||
matrixClient.someMethod(arg1, arg2).nodeify(callback);
|
||||
```
|
||||
|
||||
The main thing to note is that it is problematic to discard the result of a
|
||||
promise-returning function, as that will cause exceptions to go unobserved.
|
||||
|
||||
Methods which return a promise show this in their documentation.
|
||||
|
||||
Many methods in the SDK support _both_ Node.js-style callbacks _and_ Promises,
|
||||
via an optional `callback` argument. The callback support is now deprecated:
|
||||
new methods do not include a `callback` argument, and in the future it may be
|
||||
removed from existing methods.
|
||||
|
||||
## Low level types
|
||||
|
||||
There are some low level TypeScript types exported via the `matrix-js-sdk/lib/types` entrypoint to not bloat the main entrypoint.
|
||||
| Entry point | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `matrix-js-sdk` | Primary entry point. High-level functionality, and lots of historical clutter in need of a cleanup. |
|
||||
| `matrix-js-sdk/lib/crypto-api` | Cryptography functionality. |
|
||||
| `matrix-js-sdk/lib/types` | Low-level types, reflecting data structures defined in the Matrix spec. |
|
||||
| `matrix-js-sdk/lib/testing` | Test utilities, which may be useful in test code but should not be used in production code. |
|
||||
|
||||
## Examples
|
||||
|
||||
This section provides some useful code snippets which demonstrate the
|
||||
core functionality of the SDK. These examples assume the SDK is setup like this:
|
||||
core functionality of the SDK. These examples assume the SDK is set up like this:
|
||||
|
||||
```javascript
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
@@ -215,10 +211,10 @@ const matrixClient = sdk.createClient({
|
||||
### Automatically join rooms when invited
|
||||
|
||||
```javascript
|
||||
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
|
||||
if (member.membership === KnownMembership.Invite && member.userId === myUserId) {
|
||||
matrixClient.joinRoom(member.roomId).then(function () {
|
||||
console.log("Auto-joined %s", member.roomId);
|
||||
matrixClient.on(RoomEvent.MyMembership, function (room, membership, prevMembership) {
|
||||
if (membership === KnownMembership.Invite) {
|
||||
matrixClient.joinRoom(room.roomId).then(function () {
|
||||
console.log("Auto-joined %s", room.roomId);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -306,6 +302,9 @@ Then visit `http://localhost:8005` to see the API docs.
|
||||
|
||||
# End-to-end encryption support
|
||||
|
||||
**This section is outdated.** Use of `libolm` is deprecated and we are replacing it with support
|
||||
from the matrix-rust-sdk (https://github.com/element-hq/element-web/issues/21972).
|
||||
|
||||
The SDK supports end-to-end encryption via the Olm and Megolm protocols, using
|
||||
[libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
|
||||
application to make libolm available, via the `Olm` global.
|
||||
|
||||
@@ -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;
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "31.6.0-rc.0",
|
||||
"version": "32.4.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.6.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -80,7 +81,6 @@
|
||||
"@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",
|
||||
@@ -102,11 +102,11 @@
|
||||
"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-unicorn": "^52.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -115,12 +115,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",
|
||||
"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",
|
||||
|
||||
@@ -81,33 +81,37 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
beforeEach(
|
||||
async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
});
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
});
|
||||
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
|
||||
// Silence warnings from the backup manager
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND" },
|
||||
});
|
||||
// Silence warnings from the backup manager
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND" },
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
await initCrypto(aliceClient);
|
||||
},
|
||||
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
|
||||
10000,
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
@@ -343,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", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import * as testUtils from "../../test-utils/test-utils";
|
||||
import {
|
||||
advanceTimersUntil,
|
||||
CRYPTO_BACKENDS,
|
||||
emitPromise,
|
||||
getSyncResponse,
|
||||
InitCrypto,
|
||||
mkEventCustom,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
HistoryVisibility,
|
||||
IClaimOTKsResult,
|
||||
IContent,
|
||||
IDownloadKeyResult,
|
||||
@@ -59,11 +61,11 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomStateEvent,
|
||||
HistoryVisibility,
|
||||
} from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
@@ -80,12 +82,12 @@ import { SecretStorageKeyDescription } from "../../../src/secret-storage";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
CryptoCallbacks,
|
||||
DecryptionFailureCode,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
KeyBackupInfo,
|
||||
} from "../../../src/crypto-api";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { DecryptionError } from "../../../src/crypto/algorithms";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import {
|
||||
createOlmAccount,
|
||||
@@ -99,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(() => {
|
||||
@@ -106,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();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -465,56 +470,58 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
|
||||
describe("Unable to decrypt error codes", function () {
|
||||
it("Encryption fails with expected UISI error", async () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
});
|
||||
|
||||
it("Decryption fails with UISI error", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const awaitUISI = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
if (error.code == "MEGOLM_UNKNOWN_INBOUND_SESSION_ID") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
// 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(),
|
||||
};
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitUISI;
|
||||
const ev = await awaitDecryption;
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID);
|
||||
});
|
||||
|
||||
it("Encryption fails with expected Unknown Index error", async () => {
|
||||
it("Decryption fails with Unknown Index error", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const awaitUnknownIndex = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
if (error.code == "OLM_UNKNOWN_MESSAGE_INDEX") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
|
||||
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
|
||||
|
||||
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] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -522,23 +529,168 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitUnknownIndex;
|
||||
const ev = await awaitDecryption;
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
|
||||
});
|
||||
|
||||
it("Encryption fails with Unable to decrypt for other errors", async () => {
|
||||
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 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",
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("Decryption fails with Unable to decrypt for other errors", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
await aliceClient.getCrypto()!.importRoomKeys([testData.MEGOLM_SESSION_DATA]);
|
||||
|
||||
const awaitDecryptionError = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev) => {
|
||||
// rust and libolm can't have an exact 1:1 mapping for all errors,
|
||||
// but some errors are part of API and should match
|
||||
if (
|
||||
error.code != "MEGOLM_UNKNOWN_INBOUND_SESSION_ID" &&
|
||||
error.code != "OLM_UNKNOWN_MESSAGE_INDEX"
|
||||
ev.decryptionFailureReason !== DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID &&
|
||||
ev.decryptionFailureReason !== DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
@@ -1008,10 +1160,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();
|
||||
@@ -1928,7 +2076,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expectAliceKeyQuery({ device_keys: { "@other:user": {} }, failures: {} });
|
||||
aliceClient.on(RoomStateEvent.NewMember, (_e, _s, member: RoomMember) => {
|
||||
if (member.userId == "@other:user") {
|
||||
aliceClient.sendMessage(testRoomId, { msgtype: "m.text", body: "Hello, World" });
|
||||
aliceClient.sendMessage(testRoomId, { msgtype: MsgType.Text, body: "Hello, World" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2195,10 +2343,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) => {
|
||||
@@ -2261,10 +2405,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 = {
|
||||
@@ -2753,10 +2893,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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -192,28 +193,31 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
beforeEach(
|
||||
async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
} /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */,
|
||||
10000,
|
||||
);
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
|
||||
@@ -239,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { logger } from "../../../src/logger";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent, MsgType } from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
|
||||
@@ -217,7 +217,7 @@ async function expectBobSendMessageRequest(): Promise<OlmPayload> {
|
||||
}
|
||||
|
||||
function sendMessage(client: MatrixClient): Promise<ISendEventResponse> {
|
||||
return client.sendMessage(roomId, { msgtype: "m.text", body: "Hello, World" });
|
||||
return client.sendMessage(roomId, { msgtype: MsgType.Text, body: "Hello, World" });
|
||||
}
|
||||
|
||||
async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise<IContent> {
|
||||
|
||||
@@ -162,6 +162,110 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
@@ -1710,6 +1719,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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import HttpBackend from "matrix-mock-request";
|
||||
|
||||
import { EventStatus, RoomEvent, MatrixClient, MatrixScheduler } from "../../src/matrix";
|
||||
import { EventStatus, MatrixClient, MatrixScheduler, MsgType, RoomEvent } from "../../src/matrix";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { TestClient } from "../TestClient";
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("MatrixClient retrying", function () {
|
||||
// send a couple of events; the second will be queued
|
||||
const p1 = client!
|
||||
.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
msgtype: MsgType.Text,
|
||||
body: "m1",
|
||||
})
|
||||
.then(
|
||||
@@ -77,7 +77,7 @@ describe("MatrixClient retrying", function () {
|
||||
// never gets resolved.
|
||||
// https://github.com/matrix-org/matrix-js-sdk/issues/496
|
||||
client!.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
msgtype: MsgType.Text,
|
||||
body: "m2",
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
IndexedDBStore,
|
||||
RelationType,
|
||||
EventType,
|
||||
MatrixEventEvent,
|
||||
} from "../../src";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
|
||||
@@ -46,6 +47,7 @@ import * as utils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
|
||||
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { IActionsObject } from "../../src/pushprocessor";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
@@ -1646,6 +1648,99 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should zero total notifications for threads when absent from the notifications object", async () => {
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
|
||||
[THREAD_ID]: {
|
||||
highlight_count: 2,
|
||||
notification_count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomOne);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
|
||||
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
|
||||
it("should zero highlight notifications for threads in encrypted rooms", async () => {
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
|
||||
[THREAD_ID]: {
|
||||
highlight_count: 2,
|
||||
notification_count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomOne);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
|
||||
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
|
||||
[THREAD_ID]: {
|
||||
highlight_count: 0,
|
||||
notification_count: 0,
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("should not zero highlight notifications for threads in encrypted rooms", async () => {
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
|
||||
[THREAD_ID]: {
|
||||
highlight_count: 2,
|
||||
notification_count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomOne);
|
||||
room!.hasEncryptionStateEvent = jest.fn().mockReturnValue(true);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(5);
|
||||
|
||||
syncData.rooms.join[roomOne][UNREAD_THREAD_NOTIFICATIONS.name] = {
|
||||
[THREAD_ID]: {
|
||||
highlight_count: 0,
|
||||
notification_count: 0,
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
|
||||
expect(room!.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(2);
|
||||
});
|
||||
|
||||
it("caches unknown threads receipts and replay them when the thread is created", async () => {
|
||||
const THREAD_ID = "$unknownthread:localhost";
|
||||
|
||||
@@ -1733,64 +1828,351 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply encrypted notification logic for events within the same sync blob", async () => {
|
||||
const roomId = "!room123:server";
|
||||
const syncData = {
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: {
|
||||
ephemeral: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
room: roomId,
|
||||
event: true,
|
||||
skey: "",
|
||||
type: EventType.RoomEncryption,
|
||||
content: {},
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: "hello",
|
||||
}),
|
||||
],
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: KnownMembership.Join,
|
||||
user: otherUserId,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: KnownMembership.Join,
|
||||
user: selfUserId,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
describe("encrypted notification logic", () => {
|
||||
let roomId: string;
|
||||
let syncData: ISyncResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
roomId = "!room123:server";
|
||||
syncData = {
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: {
|
||||
ephemeral: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
room: roomId,
|
||||
event: true,
|
||||
skey: "",
|
||||
type: EventType.RoomEncryption,
|
||||
content: {},
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: "hello",
|
||||
}),
|
||||
],
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: KnownMembership.Join,
|
||||
user: otherUserId,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: KnownMembership.Join,
|
||||
user: selfUserId,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
} as unknown as ISyncResponse;
|
||||
});
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
it("should apply encrypted notification logic for events within the same sync blob", async () => {
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
|
||||
it("should recalculate highlights on unthreaded receipt for encrypted rooms", async () => {
|
||||
const myUserId = client!.getUserId()!;
|
||||
|
||||
const firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
|
||||
|
||||
// add a receipt for the first event in the room (let's say the user has already read that one)
|
||||
syncData.rooms.join[roomId].ephemeral.events = [
|
||||
{
|
||||
content: {
|
||||
[firstEventId]: {
|
||||
"m.read": {
|
||||
[myUserId]: { ts: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
},
|
||||
];
|
||||
|
||||
// Now add a highlighting event after that receipt
|
||||
const pingEvent = utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: client?.getUserId() + " ping",
|
||||
}) as IRoomEvent;
|
||||
syncData.rooms.join[roomId].timeline.events.push(pingEvent);
|
||||
|
||||
// fudge this to make it a highlight
|
||||
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
|
||||
if (ev.getId() === pingEvent.event_id) {
|
||||
return {
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
// the room should now have one highlight since our receipt was before the ping message
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
|
||||
});
|
||||
|
||||
it("should recalculate highlights on main thread receipt for encrypted rooms", async () => {
|
||||
const myUserId = client!.getUserId()!;
|
||||
|
||||
const firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
|
||||
|
||||
// add a receipt for the first event in the room (let's say the user has already read that one)
|
||||
syncData.rooms.join[roomId].ephemeral.events = [
|
||||
{
|
||||
content: {
|
||||
[firstEventId]: {
|
||||
"m.read": {
|
||||
[myUserId]: { ts: 1, thread_id: "main" },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
},
|
||||
];
|
||||
|
||||
// Now add a highlighting event after that receipt
|
||||
const pingEvent = utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: client?.getUserId() + " ping",
|
||||
}) as IRoomEvent;
|
||||
syncData.rooms.join[roomId].timeline.events.push(pingEvent);
|
||||
|
||||
// fudge this to make it a highlight
|
||||
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
|
||||
if (ev.getId() === pingEvent.event_id) {
|
||||
return {
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
// the room should now have one highlight since our receipt was before the ping message
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
|
||||
});
|
||||
|
||||
describe("notification processing in threads", () => {
|
||||
let threadEvent1: IRoomEvent;
|
||||
let threadEvent2: IRoomEvent;
|
||||
let firstEventId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
firstEventId = syncData.rooms.join[roomId].timeline.events[1].event_id;
|
||||
|
||||
// Add a threaded event off of the first event
|
||||
threadEvent1 = utils.mkEvent({
|
||||
type: EventType.RoomMessage,
|
||||
user: otherUserId,
|
||||
room: roomId,
|
||||
ts: 500,
|
||||
content: {
|
||||
"body": "first thread response",
|
||||
"m.relates_to": {
|
||||
"event_id": firstEventId,
|
||||
"m.in_reply_to": {
|
||||
event_id: firstEventId,
|
||||
},
|
||||
"rel_type": "io.element.thread",
|
||||
},
|
||||
},
|
||||
}) as IRoomEvent;
|
||||
syncData.rooms.join[roomId].timeline.events.push(threadEvent1);
|
||||
|
||||
// ...and another
|
||||
threadEvent2 = utils.mkEvent({
|
||||
type: EventType.RoomMessage,
|
||||
user: otherUserId,
|
||||
room: roomId,
|
||||
ts: 1500,
|
||||
content: {
|
||||
"body": "second thread response",
|
||||
"m.relates_to": {
|
||||
"event_id": firstEventId,
|
||||
"m.in_reply_to": {
|
||||
event_id: firstEventId,
|
||||
},
|
||||
"rel_type": "io.element.thread",
|
||||
},
|
||||
},
|
||||
}) as IRoomEvent;
|
||||
syncData.rooms.join[roomId].timeline.events.push(threadEvent2);
|
||||
|
||||
// fudge to make these highlights
|
||||
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
|
||||
if ([threadEvent1.event_id, threadEvent2.event_id].includes(ev.getId()!)) {
|
||||
return {
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
it("checks threads with notifications on unthreaded receipts", async () => {
|
||||
const myUserId = client!.getUserId()!;
|
||||
|
||||
// add a receipt for a random, ficticious thread, otherwise the client will
|
||||
// think that the thread is before any threaded receipts and ignore it.
|
||||
syncData.rooms.join[roomId].ephemeral.events = [
|
||||
{
|
||||
content: {
|
||||
[firstEventId]: {
|
||||
"m.read": {
|
||||
[myUserId]: { ts: 1, thread_id: "some_other_thread" },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
},
|
||||
];
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient({ threadSupport: true });
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
|
||||
// pretend that the client has decrypted an event to trigger it to compute
|
||||
// local notifications
|
||||
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(firstEventId)!);
|
||||
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(threadEvent1.event_id)!);
|
||||
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(threadEvent2.event_id)!);
|
||||
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
|
||||
// we should now have one highlight: the unread message that pings
|
||||
expect(
|
||||
room.getThreadUnreadNotificationCount(firstEventId, NotificationCountType.Highlight),
|
||||
).toEqual(2);
|
||||
|
||||
const syncData2 = {
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: {
|
||||
ephemeral: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
[firstEventId]: {
|
||||
"m.read": {
|
||||
[myUserId]: { ts: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData2);
|
||||
|
||||
await Promise.all([httpBackend!.flush("/sync", 1), utils.syncPromise(client!)]);
|
||||
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("should recalculate highlights on threaded receipt for encrypted rooms", async () => {
|
||||
const myUserId = client!.getUserId()!;
|
||||
|
||||
// add a receipt for the first message in the threadm leaving the second one unread
|
||||
syncData.rooms.join[roomId].ephemeral.events = [
|
||||
{
|
||||
content: {
|
||||
[threadEvent1.event_id]: {
|
||||
"m.read": {
|
||||
[myUserId]: { ts: 1, thread_id: firstEventId },
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "m.receipt",
|
||||
},
|
||||
];
|
||||
|
||||
// fudge to make both thread replies highlights
|
||||
client!.getPushActionsForEvent = (ev: MatrixEvent): IActionsObject | null => {
|
||||
if ([threadEvent1.event_id, threadEvent2.event_id].includes(ev.getId()!)) {
|
||||
return {
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient({ threadSupport: true });
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
|
||||
// pretend that the client has decrypted an event to trigger it to compute
|
||||
// local notifications
|
||||
client?.emit(MatrixEventEvent.Decrypted, room.findEventById(firstEventId)!);
|
||||
|
||||
// the room should now have one highlight: the second thread message
|
||||
|
||||
expect(room.getThreadUnreadNotificationCount(firstEventId, NotificationCountType.Highlight)).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("MatrixClient syncing", () => {
|
||||
await client!.sendEvent(roomId, EventType.Reaction, {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: threadReply.getId(),
|
||||
event_id: threadReply.getId()!,
|
||||
key: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
|
||||
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"],
|
||||
|
||||
@@ -245,6 +245,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 +278,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",
|
||||
};
|
||||
|
||||
@@ -76,5 +76,29 @@ 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/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/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/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,20 @@ class MockWidgetApi extends EventEmitter {
|
||||
public transport = { reply: jest.fn() };
|
||||
}
|
||||
|
||||
declare module "../../src/types" {
|
||||
interface StateEvents {
|
||||
"org.example.foo": {
|
||||
hello: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TimelineEvents {
|
||||
"org.matrix.rageshake_request": {
|
||||
request_id: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("RoomWidgetClient", () => {
|
||||
let widgetApi: MockedObject<WidgetApi>;
|
||||
let client: MatrixClient;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Filter } from "../../src/filter";
|
||||
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
@@ -38,30 +39,31 @@ import * as testUtils from "../test-utils/test-utils";
|
||||
import { makeBeaconInfoContent } from "../../src/content-helpers";
|
||||
import { M_BEACON_INFO } from "../../src/@types/beacon";
|
||||
import {
|
||||
ContentHelpers,
|
||||
ClientPrefix,
|
||||
ConditionKind,
|
||||
ContentHelpers,
|
||||
Direction,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
getHttpUriForMxc,
|
||||
ICreateRoomOpts,
|
||||
IPushRule,
|
||||
IRequestOpts,
|
||||
MatrixError,
|
||||
MatrixHttpApi,
|
||||
MatrixScheduler,
|
||||
Method,
|
||||
Room,
|
||||
EventTimelineSet,
|
||||
PushRuleActionName,
|
||||
TweakName,
|
||||
Room,
|
||||
RuleId,
|
||||
IPushRule,
|
||||
ConditionKind,
|
||||
getHttpUriForMxc,
|
||||
TweakName,
|
||||
} from "../../src";
|
||||
import { supportsMatrixCall } from "../../src/webrtc/call";
|
||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||
import {
|
||||
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
|
||||
POLICIES_ACCOUNT_EVENT_TYPE,
|
||||
PolicyRecommendation,
|
||||
PolicyScope,
|
||||
} from "../../src/models/invites-ignorer";
|
||||
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
|
||||
@@ -72,6 +74,7 @@ import { StubStore } from "../../src/store/stub";
|
||||
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { RoomMessageEventContent } from "../../src/@types/events";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -383,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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -566,7 +572,7 @@ describe("MatrixClient", function () {
|
||||
describe("sendEvent", () => {
|
||||
const roomId = "!room:example.org";
|
||||
const body = "This is the body";
|
||||
const content = { body };
|
||||
const content = { body, msgtype: MsgType.Text } satisfies RoomMessageEventContent;
|
||||
|
||||
it("overload without threadId works", async () => {
|
||||
const eventId = "$eventId:example.org";
|
||||
@@ -661,12 +667,13 @@ describe("MatrixClient", function () {
|
||||
|
||||
const content = {
|
||||
body,
|
||||
"msgtype": MsgType.Text,
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$other:event",
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies RoomMessageEventContent;
|
||||
|
||||
const room = new Room(roomId, client, userId);
|
||||
mocked(store.getRoom).mockReturnValue(room);
|
||||
@@ -1527,8 +1534,6 @@ describe("MatrixClient", function () {
|
||||
{ startOpts: {}, hasThreadSupport: false },
|
||||
{ startOpts: { threadSupport: true }, hasThreadSupport: true },
|
||||
{ startOpts: { threadSupport: false }, hasThreadSupport: false },
|
||||
{ startOpts: { experimentalThreadSupport: true }, hasThreadSupport: true },
|
||||
{ startOpts: { experimentalThreadSupport: true, threadSupport: false }, hasThreadSupport: false },
|
||||
])("enabled thread support for the SDK instance", async ({ startOpts, hasThreadSupport }) => {
|
||||
await client.startClient(startOpts);
|
||||
expect(client.supportsThreads()).toBe(hasThreadSupport);
|
||||
@@ -2082,10 +2087,10 @@ describe("MatrixClient", function () {
|
||||
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
|
||||
|
||||
// Add a rule in the new source room.
|
||||
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, {
|
||||
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, EventType.PolicyRuleUser, {
|
||||
entity: "*:example.org",
|
||||
reason: "just a test",
|
||||
recommendation: "m.ban",
|
||||
recommendation: PolicyRecommendation.Ban,
|
||||
});
|
||||
|
||||
// We should reject this invite.
|
||||
@@ -2172,8 +2177,8 @@ describe("MatrixClient", function () {
|
||||
// Check where it shows up.
|
||||
const targetRoomId = ignoreInvites2.target;
|
||||
const targetRoom = client.getRoom(targetRoomId) as WrappedRoom;
|
||||
expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy();
|
||||
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
|
||||
expect(targetRoom._state.get(EventType.PolicyRuleUser)[eventId]).toBeTruthy();
|
||||
expect(newSourceRoom._state.get(EventType.PolicyRuleUser)?.[eventId]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { DEFAULT_ALPHABET } from "../../../src/utils";
|
||||
import { MatrixError } from "../../../src/http-api";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { EncryptedFile } from "../../../src/@types/media";
|
||||
|
||||
describe("MSC3089TreeSpace", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -947,7 +948,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
const fileInfo = {
|
||||
mimetype: "text/plain",
|
||||
// other fields as required by encryption, but ignored here
|
||||
};
|
||||
} as unknown as EncryptedFile;
|
||||
const fileEventId = "$file";
|
||||
const fileName = "My File.txt";
|
||||
const fileContents = "This is a test file";
|
||||
@@ -1007,7 +1008,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
const fileInfo = {
|
||||
mimetype: "text/plain",
|
||||
// other fields as required by encryption, but ignored here
|
||||
};
|
||||
} as unknown as EncryptedFile;
|
||||
const fileEventId = "$file";
|
||||
const fileName = "My File.txt";
|
||||
const fileContents = "This is a test file";
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
TweakName,
|
||||
} from "../../../src";
|
||||
import { DecryptionFailureCode } from "../../../src/crypto-api";
|
||||
import { DecryptionError } from "../../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
it("should create copies of itself", () => {
|
||||
@@ -360,20 +362,50 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should report decryption errors", async () => {
|
||||
it("should report unknown decryption errors", async () => {
|
||||
const decryptionListener = jest.fn();
|
||||
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
|
||||
|
||||
const testError = new Error("test error");
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
|
||||
decryptEvent: jest.fn().mockRejectedValue(testError),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: Error: test error **",
|
||||
});
|
||||
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
|
||||
});
|
||||
|
||||
it("should report known decryption errors", async () => {
|
||||
const decryptionListener = jest.fn();
|
||||
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
|
||||
|
||||
const testError = new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, "uisi");
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockRejectedValue(testError),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: DecryptionError: uisi **",
|
||||
});
|
||||
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
|
||||
});
|
||||
|
||||
it(`should report "DecryptionError: The sender has disabled encrypting to unverified devices."`, async () => {
|
||||
@@ -423,6 +455,8 @@ describe("MatrixEvent", () => {
|
||||
expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2);
|
||||
expect(crypto.decryptEvent).toHaveBeenCalledTimes(2);
|
||||
expect(encryptedEvent.getType()).toEqual("m.room.message");
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBe(false);
|
||||
expect(encryptedEvent.decryptionFailureReason).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,52 +503,6 @@ describe("MatrixEvent", () => {
|
||||
default: false,
|
||||
enabled: true,
|
||||
} as IAnnotatedPushRule;
|
||||
describe("setPushActions()", () => {
|
||||
it("sets actions on event", () => {
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushActions(actions);
|
||||
|
||||
expect(event.getPushActions()).toBe(actions);
|
||||
});
|
||||
|
||||
it("sets actions to undefined", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushActions(null);
|
||||
|
||||
// undefined is set on state
|
||||
expect(event.getPushDetails().actions).toBe(undefined);
|
||||
// but pushActions getter returns null when falsy
|
||||
expect(event.getPushActions()).toBe(null);
|
||||
});
|
||||
|
||||
it("clears existing push rule", () => {
|
||||
const prevActions = { notify: true, tweaks: { highlight: true } };
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushDetails(prevActions, pushRule);
|
||||
|
||||
event.setPushActions(actions);
|
||||
|
||||
// rule is not in event push cache
|
||||
expect(event.getPushDetails()).toEqual({ actions });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPushDetails()", () => {
|
||||
it("sets actions and rule on event", () => {
|
||||
@@ -543,7 +531,7 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
event.setPushDetails(prevActions, pushRule);
|
||||
|
||||
event.setPushActions(actions);
|
||||
event.setPushDetails(actions);
|
||||
|
||||
// rule is not in event push cache
|
||||
expect(event.getPushDetails()).toEqual({ actions });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+320
-38
@@ -1,8 +1,35 @@
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { IActionsObject, PushProcessor } from "../../src/pushprocessor";
|
||||
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName, RuleId } from "../../src";
|
||||
import {
|
||||
ConditionKind,
|
||||
EventType,
|
||||
IContent,
|
||||
IPushRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PushRuleActionName,
|
||||
RuleId,
|
||||
TweakName,
|
||||
} from "../../src";
|
||||
import { mockClientMethodsUser } from "../test-utils/client";
|
||||
|
||||
const msc3914RoomCallRule: IPushRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: "type",
|
||||
pattern: "org.matrix.msc3401.call",
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.CallStarted,
|
||||
},
|
||||
],
|
||||
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }],
|
||||
};
|
||||
|
||||
describe("NotificationService", function () {
|
||||
const testUserId = "@ali:matrix.org";
|
||||
const testDisplayName = "Alice M";
|
||||
@@ -12,23 +39,6 @@ describe("NotificationService", function () {
|
||||
|
||||
let pushProcessor: PushProcessor;
|
||||
|
||||
const msc3914RoomCallRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "org.matrix.msc3401.call",
|
||||
},
|
||||
{
|
||||
kind: "call_started",
|
||||
},
|
||||
],
|
||||
actions: ["notify", { set_tweak: "sound", value: "default" }],
|
||||
};
|
||||
|
||||
let matrixClient: MatrixClient;
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -187,26 +197,6 @@ describe("NotificationService", function () {
|
||||
pushProcessor = new PushProcessor(matrixClient);
|
||||
});
|
||||
|
||||
it("should add default rules in the correct order", () => {
|
||||
// By the time we get here, we expect the PushProcessor to have merged the new .m.rule.is_room_mention rule into the existing list of rules.
|
||||
// Check that has happened, and that it is in the right place.
|
||||
const containsDisplayNameRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
|
||||
(rule) => rule.rule_id === RuleId.ContainsDisplayName,
|
||||
);
|
||||
expect(containsDisplayNameRuleIdx).toBeGreaterThan(-1);
|
||||
const isRoomMentionRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
|
||||
(rule) => rule.rule_id === RuleId.IsRoomMention,
|
||||
);
|
||||
expect(isRoomMentionRuleIdx).toBeGreaterThan(-1);
|
||||
const mReactionRuleIdx = matrixClient.pushRules?.global.override?.findIndex(
|
||||
(rule) => rule.rule_id === ".m.rule.reaction",
|
||||
);
|
||||
expect(mReactionRuleIdx).toBeGreaterThan(-1);
|
||||
|
||||
expect(containsDisplayNameRuleIdx).toBeLessThan(isRoomMentionRuleIdx!);
|
||||
expect(isRoomMentionRuleIdx).toBeLessThan(mReactionRuleIdx!);
|
||||
});
|
||||
|
||||
// User IDs
|
||||
|
||||
it("should bing on a user ID.", function () {
|
||||
@@ -722,3 +712,295 @@ describe("Test PushProcessor.partsForDottedKey", function () {
|
||||
expect(PushProcessor.partsForDottedKey(path)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteDefaultRules", () => {
|
||||
it("should add default rules in the correct order", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
device: {},
|
||||
global: {
|
||||
content: [],
|
||||
override: [
|
||||
// Include user-defined push rules inbetween .m.rule.master and other default rules to assert they are maintained in-order.
|
||||
{
|
||||
rule_id: ".m.rule.master",
|
||||
default: true,
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Sound,
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "coffee",
|
||||
rule_id: "coffee",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Sound,
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.ContainsDisplayName,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
default: true,
|
||||
rule_id: ".m.rule.contains_display_name",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Sound,
|
||||
value: "default",
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
is: "2",
|
||||
kind: ConditionKind.RoomMemberCount,
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.room_one_to_one",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
room: [],
|
||||
sender: [],
|
||||
underride: [
|
||||
{
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
rule_id: "user-defined",
|
||||
default: false,
|
||||
},
|
||||
msc3914RoomCallRule,
|
||||
{
|
||||
actions: [
|
||||
PushRuleActionName.Notify,
|
||||
{
|
||||
set_tweak: TweakName.Highlight,
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.fallback",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// By the time we get here, we expect the PushProcessor to have merged the new .m.rule.is_room_mention rule into the existing list of rules.
|
||||
// Check that has happened, and that it is in the right place.
|
||||
const containsDisplayNameRuleIdx = pushRules.global.override?.findIndex(
|
||||
(rule) => rule.rule_id === RuleId.ContainsDisplayName,
|
||||
);
|
||||
expect(containsDisplayNameRuleIdx).toBeGreaterThan(-1);
|
||||
const isRoomMentionRuleIdx = pushRules.global.override?.findIndex(
|
||||
(rule) => rule.rule_id === RuleId.IsRoomMention,
|
||||
);
|
||||
expect(isRoomMentionRuleIdx).toBeGreaterThan(-1);
|
||||
const mReactionRuleIdx = pushRules.global.override?.findIndex((rule) => rule.rule_id === ".m.rule.reaction");
|
||||
expect(mReactionRuleIdx).toBeGreaterThan(-1);
|
||||
|
||||
expect(containsDisplayNameRuleIdx).toBeLessThan(isRoomMentionRuleIdx!);
|
||||
expect(isRoomMentionRuleIdx).toBeLessThan(mReactionRuleIdx!);
|
||||
|
||||
expect(pushRules.global.override?.map((r) => r.rule_id)).toEqual([
|
||||
".m.rule.master",
|
||||
"coffee",
|
||||
".m.rule.contains_display_name",
|
||||
".m.rule.room_one_to_one",
|
||||
".m.rule.is_room_mention",
|
||||
".m.rule.reaction",
|
||||
".org.matrix.msc3786.rule.room.server_acl",
|
||||
]);
|
||||
expect(pushRules.global.underride?.map((r) => r.rule_id)).toEqual([
|
||||
"user-defined",
|
||||
".org.matrix.msc3914.rule.room.call",
|
||||
// Assert that unknown default rules are maintained
|
||||
".m.rule.fallback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add missing msc3914 rule in correct place", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
device: {},
|
||||
global: {
|
||||
// Sample push rules from a Synapse user.
|
||||
// Note that rules 2 and 3 are backwards, this will trigger a warning in the console.
|
||||
underride: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "m.call.invite",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "ring",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
rule_id: ".m.rule.call",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "m.room.message",
|
||||
},
|
||||
{
|
||||
kind: "room_member_count",
|
||||
is: "2",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "TEST1",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
rule_id: ".m.rule.room_one_to_one",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "m.room.encrypted",
|
||||
},
|
||||
{
|
||||
kind: "room_member_count",
|
||||
is: "2",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "TEST2",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
rule_id: ".m.rule.encrypted_room_one_to_one",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "m.room.message",
|
||||
},
|
||||
],
|
||||
actions: ["dont_notify"],
|
||||
rule_id: ".m.rule.message",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "m.room.encrypted",
|
||||
},
|
||||
],
|
||||
actions: ["dont_notify"],
|
||||
rule_id: ".m.rule.encrypted",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "im.vector.modular.widgets",
|
||||
},
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "content.type",
|
||||
pattern: "jitsi",
|
||||
},
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "state_key",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
rule_id: ".im.vector.jitsi",
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
] as IPushRule[],
|
||||
},
|
||||
});
|
||||
|
||||
expect(pushRules.global.underride?.map((r) => r.rule_id)).toEqual([
|
||||
".m.rule.call",
|
||||
".org.matrix.msc3914.rule.room.call",
|
||||
".m.rule.room_one_to_one",
|
||||
".m.rule.encrypted_room_one_to_one",
|
||||
".m.rule.message",
|
||||
".m.rule.encrypted",
|
||||
".im.vector.jitsi",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,10 +25,11 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { DecryptionError } from "../../src/crypto/algorithms";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
import { DecryptionError } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("RoomState", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -1040,7 +1041,9 @@ describe("RoomState", function () {
|
||||
content: beacon1RelationContent,
|
||||
});
|
||||
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
|
||||
mockClient.decryptEventIfNeeded.mockRejectedValue(
|
||||
new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "msg"),
|
||||
);
|
||||
// spy on event.once
|
||||
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
|
||||
|
||||
|
||||
+73
-23
@@ -2831,11 +2831,41 @@ describe("Room", function () {
|
||||
// XXX: If we add the relation to the thread response before the thread finishes fetching via /relations
|
||||
// then the test will fail
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await Promise.all([emitPromise(room, ThreadEvent.Update), room.addLiveEvents([threadResponseEdit])]);
|
||||
expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
|
||||
});
|
||||
|
||||
it("emits event for the first event added to a thread", async () => {
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const threadRoot = mkMessage();
|
||||
const threadResponse1 = mkThreadResponse(threadRoot);
|
||||
|
||||
await room.addLiveEvents([threadRoot]);
|
||||
|
||||
const onEvent = jest.fn();
|
||||
room.on(RoomEvent.Timeline, onEvent);
|
||||
|
||||
await room.addLiveEvents([threadResponse1]);
|
||||
|
||||
expect(onEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("contains the events added as soon as it's created", async () => {
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
|
||||
const threadRoot = mkMessage();
|
||||
const threadResponse1 = mkThreadResponse(threadRoot);
|
||||
|
||||
const newThreadEventPromise = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([threadRoot, threadResponse1]);
|
||||
const thread = await newThreadEventPromise;
|
||||
|
||||
expect(thread.timeline).toContain(threadResponse1);
|
||||
});
|
||||
|
||||
it("Redactions to thread responses decrement the length", async () => {
|
||||
room.client.supportsThreads = () => true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
@@ -2864,7 +2894,6 @@ describe("Room", function () {
|
||||
let prom = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
|
||||
const thread = await prom;
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
|
||||
expect(thread).toHaveLength(2);
|
||||
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
|
||||
@@ -2929,6 +2958,10 @@ describe("Room", function () {
|
||||
},
|
||||
});
|
||||
|
||||
room.client.fetchRelations = jest.fn().mockResolvedValue({
|
||||
chunk: [threadResponse2Reaction.event, threadResponse2.event, threadResponse1.event],
|
||||
});
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
|
||||
const thread = await prom;
|
||||
@@ -2969,18 +3002,20 @@ describe("Room", function () {
|
||||
},
|
||||
});
|
||||
|
||||
let prom = emitPromise(room, ThreadEvent.New);
|
||||
const prom = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
|
||||
const thread = await prom;
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
|
||||
expect(thread).toHaveLength(2);
|
||||
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
|
||||
|
||||
prom = emitPromise(room, ThreadEvent.Update);
|
||||
const threadRootRedaction = mkRedaction(threadRoot);
|
||||
await room.addLiveEvents([threadRootRedaction]);
|
||||
await prom;
|
||||
|
||||
// We can't wait for a thread update here because there shouldn't be one (which is
|
||||
// what we're asserting). Flush any promises to try to get more certainty that an
|
||||
// update is not happening some time after the event is added.
|
||||
await flushPromises();
|
||||
expect(thread).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -3058,7 +3093,6 @@ describe("Room", function () {
|
||||
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
const threadResponse2Redaction = mkRedaction(threadResponse2);
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await room.addLiveEvents([threadResponse2Redaction]);
|
||||
expect(thread).toHaveLength(1);
|
||||
expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId());
|
||||
@@ -3514,7 +3548,7 @@ describe("Room", function () {
|
||||
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
|
||||
|
||||
room.resetThreadUnreadNotificationCount();
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(room.threadsAggregateNotificationType).toBe(null);
|
||||
|
||||
@@ -3531,16 +3565,6 @@ describe("Room", function () {
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
|
||||
});
|
||||
|
||||
it("partially resets room notifications", () => {
|
||||
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
|
||||
room.setThreadUnreadNotificationCount("456", NotificationCountType.Highlight, 123);
|
||||
|
||||
room.resetThreadUnreadNotificationCount(["123"]);
|
||||
|
||||
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(666);
|
||||
expect(room.getThreadUnreadNotificationCount("456", NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("emits event on notifications reset", () => {
|
||||
const cb = jest.fn();
|
||||
|
||||
@@ -3549,7 +3573,7 @@ describe("Room", function () {
|
||||
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
|
||||
room.setThreadUnreadNotificationCount("456", NotificationCountType.Highlight, 123);
|
||||
|
||||
room.resetThreadUnreadNotificationCount();
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(cb).toHaveBeenLastCalledWith();
|
||||
});
|
||||
@@ -3571,10 +3595,10 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
it("lets you reset", () => {
|
||||
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 1);
|
||||
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
|
||||
expect(room.hasThreadUnreadNotification()).toBe(true);
|
||||
|
||||
room.resetThreadUnreadNotificationCount();
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(room.hasThreadUnreadNotification()).toBe(false);
|
||||
});
|
||||
@@ -3602,12 +3626,38 @@ describe("Room", function () {
|
||||
it("allows reset", () => {
|
||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
|
||||
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
|
||||
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(room.threadsAggregateNotificationType).toBeNull();
|
||||
});
|
||||
|
||||
it("retains highlight for encrypted rooms on reset", () => {
|
||||
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(true);
|
||||
|
||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
|
||||
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
|
||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
|
||||
|
||||
room.resetThreadUnreadNotificationCount();
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(room.threadsAggregateNotificationType).toBeNull();
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
|
||||
});
|
||||
|
||||
it("resets highlight for unencrypted rooms on reset", () => {
|
||||
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(false);
|
||||
|
||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
|
||||
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
|
||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
|
||||
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
|
||||
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
|
||||
expect(room.threadsAggregateNotificationType).toBe(null);
|
||||
expect(room.getThreadUnreadNotificationCount("$123", NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -205,7 +205,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 +304,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 +825,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) {
|
||||
@@ -1395,6 +1461,34 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
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 {
|
||||
decryptExistingEvent,
|
||||
mkDecryptionFailureMatrixEvent,
|
||||
mkEncryptedMatrixEvent,
|
||||
mkMatrixEvent,
|
||||
} from "../../src/testing";
|
||||
import { EventType } from "../../src";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
|
||||
describe("testing", () => {
|
||||
describe("mkMatrixEvent", () => {
|
||||
it("makes an event", () => {
|
||||
const event = mkMatrixEvent({
|
||||
content: { body: "blah" },
|
||||
sender: "@alice:test",
|
||||
type: EventType.RoomMessage,
|
||||
roomId: "!test:room",
|
||||
});
|
||||
|
||||
expect(event.getContent()).toEqual({ body: "blah" });
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isState()).toBe(false);
|
||||
});
|
||||
|
||||
it("makes a state event", () => {
|
||||
const event = mkMatrixEvent({
|
||||
content: { body: "blah" },
|
||||
sender: "@alice:test",
|
||||
type: EventType.RoomTopic,
|
||||
roomId: "!test:room",
|
||||
stateKey: "",
|
||||
});
|
||||
|
||||
expect(event.getContent()).toEqual({ body: "blah" });
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isState()).toBe(true);
|
||||
expect(event.getStateKey()).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mkEncryptedMatrixEvent", () => {
|
||||
it("makes an event", async () => {
|
||||
const event = await mkEncryptedMatrixEvent({
|
||||
plainContent: { body: "blah" },
|
||||
sender: "@alice:test",
|
||||
plainType: EventType.RoomMessage,
|
||||
roomId: "!test:room",
|
||||
});
|
||||
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(false);
|
||||
expect(event.decryptionFailureReason).toBe(null);
|
||||
expect(event.getContent()).toEqual({ body: "blah" });
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mkDecryptionFailureMatrixEvent", () => {
|
||||
it("makes an event", async () => {
|
||||
const event = await mkDecryptionFailureMatrixEvent({
|
||||
sender: "@alice:test",
|
||||
roomId: "!test:room",
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
msg: "blah",
|
||||
});
|
||||
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(true);
|
||||
expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
expect(event.getContent()).toEqual({
|
||||
body: "** Unable to decrypt: DecryptionError: blah **",
|
||||
msgtype: "m.bad.encrypted",
|
||||
});
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
expect(event.isState()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptExistingEvent", () => {
|
||||
it("decrypts an event", async () => {
|
||||
const event = await mkDecryptionFailureMatrixEvent({
|
||||
sender: "@alice:test",
|
||||
roomId: "!test:room",
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
msg: "blah",
|
||||
});
|
||||
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(true);
|
||||
await decryptExistingEvent(event, {
|
||||
plainContent: { body: "blah" },
|
||||
plainType: "m.room.test",
|
||||
});
|
||||
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(false);
|
||||
expect(event.decryptionFailureReason).toBe(null);
|
||||
expect(event.getContent()).toEqual({ body: "blah" });
|
||||
expect(event.getType()).toEqual("m.room.test");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,3 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
export type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
// Based on https://stackoverflow.com/a/53229857/3532235
|
||||
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
|
||||
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
+123
-13
@@ -15,6 +15,49 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import {
|
||||
PolicyRuleEventContent,
|
||||
RoomAvatarEventContent,
|
||||
RoomCanonicalAliasEventContent,
|
||||
RoomCreateEventContent,
|
||||
RoomEncryptionEventContent,
|
||||
RoomGuestAccessEventContent,
|
||||
RoomHistoryVisibilityEventContent,
|
||||
RoomJoinRulesEventContent,
|
||||
RoomMemberEventContent,
|
||||
RoomNameEventContent,
|
||||
RoomPinnedEventsEventContent,
|
||||
RoomPowerLevelsEventContent,
|
||||
RoomServerAclEventContent,
|
||||
RoomThirdPartyInviteEventContent,
|
||||
RoomTombstoneEventContent,
|
||||
RoomTopicEventContent,
|
||||
SpaceChildEventContent,
|
||||
SpaceParentEventContent,
|
||||
} from "./state_events";
|
||||
import {
|
||||
ExperimentalGroupCallRoomMemberState,
|
||||
IGroupCallRoomMemberState,
|
||||
IGroupCallRoomState,
|
||||
} from "../webrtc/groupCall";
|
||||
import { MSC3089EventContent } from "../models/MSC3089Branch";
|
||||
import { M_BEACON, M_BEACON_INFO, MBeaconEventContent, MBeaconInfoEventContent } from "./beacon";
|
||||
import { XOR } from "./common";
|
||||
import { ReactionEventContent, RoomMessageEventContent, StickerEventContent } from "./events";
|
||||
import {
|
||||
MCallAnswer,
|
||||
MCallBase,
|
||||
MCallCandidates,
|
||||
MCallHangupReject,
|
||||
MCallInviteNegotiate,
|
||||
MCallReplacesEvent,
|
||||
MCallSelectAnswer,
|
||||
SDPStreamMetadata,
|
||||
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";
|
||||
|
||||
export enum EventType {
|
||||
// Room state events
|
||||
@@ -35,6 +78,11 @@ export enum EventType {
|
||||
RoomTombstone = "m.room.tombstone",
|
||||
RoomPredecessor = "org.matrix.msc3946.room_predecessor",
|
||||
|
||||
// Moderation policy lists
|
||||
PolicyRuleUser = "m.policy.rule.user",
|
||||
PolicyRuleRoom = "m.policy.rule.room",
|
||||
PolicyRuleServer = "m.policy.rule.server",
|
||||
|
||||
SpaceChild = "m.space.child",
|
||||
SpaceParent = "m.space.parent",
|
||||
|
||||
@@ -103,6 +151,10 @@ export enum RelationType {
|
||||
Annotation = "m.annotation",
|
||||
Replace = "m.replace",
|
||||
Reference = "m.reference",
|
||||
|
||||
// Don't use this yet: it's only the stable version. The code still assumes we support the unstable prefix and,
|
||||
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
|
||||
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
|
||||
Thread = "m.thread",
|
||||
}
|
||||
|
||||
@@ -246,17 +298,75 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
|
||||
*/
|
||||
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
|
||||
|
||||
export interface IEncryptedFile {
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
key: {
|
||||
alg: string;
|
||||
key_ops: string[]; // eslint-disable-line camelcase
|
||||
kty: string;
|
||||
k: string;
|
||||
ext: boolean;
|
||||
};
|
||||
iv: string;
|
||||
hashes: { [alg: string]: string };
|
||||
v: string;
|
||||
/**
|
||||
* https://github.com/matrix-org/matrix-spec-proposals/pull/4115
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new UnstableValue("membership", "io.element.msc4115.membership");
|
||||
|
||||
/**
|
||||
* @deprecated in favour of {@link EncryptedFile}
|
||||
*/
|
||||
export type IEncryptedFile = EncryptedFile;
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified non-state room events.
|
||||
*/
|
||||
export interface TimelineEvents {
|
||||
[EventType.RoomMessage]: RoomMessageEventContent;
|
||||
[EventType.Sticker]: StickerEventContent;
|
||||
[EventType.Reaction]: ReactionEventContent;
|
||||
[EventType.CallReplaces]: MCallReplacesEvent;
|
||||
[EventType.CallAnswer]: MCallAnswer;
|
||||
[EventType.CallSelectAnswer]: MCallSelectAnswer;
|
||||
[EventType.CallNegotiate]: Omit<MCallInviteNegotiate, "offer">;
|
||||
[EventType.CallInvite]: MCallInviteNegotiate;
|
||||
[EventType.CallCandidates]: MCallCandidates;
|
||||
[EventType.CallHangup]: MCallHangupReject;
|
||||
[EventType.CallReject]: MCallHangupReject;
|
||||
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
|
||||
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
|
||||
[EventType.CallNotify]: ICallNotifyContent;
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified room state events.
|
||||
*/
|
||||
export interface StateEvents {
|
||||
[EventType.RoomCanonicalAlias]: RoomCanonicalAliasEventContent;
|
||||
[EventType.RoomCreate]: RoomCreateEventContent;
|
||||
[EventType.RoomJoinRules]: RoomJoinRulesEventContent;
|
||||
[EventType.RoomMember]: RoomMemberEventContent;
|
||||
// XXX: Spec says this event has 3 required fields but kicking such an invitation requires sending `{}`
|
||||
[EventType.RoomThirdPartyInvite]: XOR<RoomThirdPartyInviteEventContent, {}>;
|
||||
[EventType.RoomPowerLevels]: RoomPowerLevelsEventContent;
|
||||
[EventType.RoomName]: RoomNameEventContent;
|
||||
[EventType.RoomTopic]: RoomTopicEventContent;
|
||||
[EventType.RoomAvatar]: RoomAvatarEventContent;
|
||||
[EventType.RoomPinnedEvents]: RoomPinnedEventsEventContent;
|
||||
[EventType.RoomEncryption]: RoomEncryptionEventContent;
|
||||
[EventType.RoomHistoryVisibility]: RoomHistoryVisibilityEventContent;
|
||||
[EventType.RoomGuestAccess]: RoomGuestAccessEventContent;
|
||||
[EventType.RoomServerAcl]: RoomServerAclEventContent;
|
||||
[EventType.RoomTombstone]: RoomTombstoneEventContent;
|
||||
[EventType.SpaceChild]: SpaceChildEventContent;
|
||||
[EventType.SpaceParent]: SpaceParentEventContent;
|
||||
|
||||
[EventType.PolicyRuleUser]: XOR<PolicyRuleEventContent, {}>;
|
||||
[EventType.PolicyRuleRoom]: XOR<PolicyRuleEventContent, {}>;
|
||||
[EventType.PolicyRuleServer]: XOR<PolicyRuleEventContent, {}>;
|
||||
|
||||
// MSC3401
|
||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
|
||||
|
||||
// MSC3089
|
||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||
|
||||
// MSC3672
|
||||
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { MsgType, RelationType } from "./event";
|
||||
import { FileInfo, ImageInfo, MediaEventContent } from "./media";
|
||||
import { XOR } from "./common";
|
||||
|
||||
interface BaseTimelineEvent {
|
||||
"body": string;
|
||||
"m.mentions"?: {
|
||||
user_ids?: string[];
|
||||
room?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReplyEvent {
|
||||
"m.relates_to"?: {
|
||||
"m.in_reply_to"?: {
|
||||
event_id: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface NoRelationEvent {
|
||||
"m.new_content"?: never;
|
||||
"m.relates_to"?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial content format of timeline events with rel_type `m.replace`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#event-replacements
|
||||
*/
|
||||
export interface ReplacementEvent<T> {
|
||||
"m.new_content": T;
|
||||
"m.relates_to": {
|
||||
event_id: string;
|
||||
rel_type: RelationType.Replace;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial content format of timeline events with rel_type other than `m.replace`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#forming-relationships-between-events
|
||||
*/
|
||||
export interface RelationEvent {
|
||||
"m.new_content"?: never;
|
||||
"m.relates_to": {
|
||||
event_id: string;
|
||||
rel_type: Exclude<RelationType, RelationType.Replace>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content format of timeline events with type `m.room.message` and `msgtype` `m.text`, `m.emote`, or `m.notice`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#mroommessage
|
||||
*/
|
||||
export interface RoomMessageTextEventContent extends BaseTimelineEvent {
|
||||
msgtype: MsgType.Text | MsgType.Emote | MsgType.Notice;
|
||||
format?: "org.matrix.custom.html";
|
||||
formatted_body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content format of timeline events with type `m.room.message` and `msgtype` `m.location`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#mlocation
|
||||
*/
|
||||
export interface RoomMessageLocationEventContent extends BaseTimelineEvent {
|
||||
body: string;
|
||||
geo_uri: string;
|
||||
info: Pick<FileInfo, "thumbnail_info" | "thumbnail_file" | "thumbnail_url">;
|
||||
msgtype: MsgType.Location;
|
||||
}
|
||||
|
||||
type MessageEventContent = RoomMessageTextEventContent | RoomMessageLocationEventContent | MediaEventContent;
|
||||
|
||||
export type RoomMessageEventContent = BaseTimelineEvent &
|
||||
XOR<XOR<ReplacementEvent<MessageEventContent>, RelationEvent>, XOR<ReplyEvent, NoRelationEvent>> &
|
||||
MessageEventContent;
|
||||
|
||||
/**
|
||||
* Content format of timeline events with type `m.sticker`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#msticker
|
||||
*/
|
||||
export interface StickerEventContent extends BaseTimelineEvent {
|
||||
body: string;
|
||||
info: ImageInfo;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content format of timeline events with type `m.reaction`
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#mreaction
|
||||
*/
|
||||
export interface ReactionEventContent {
|
||||
"m.relates_to": {
|
||||
event_id: string;
|
||||
key: string;
|
||||
rel_type: RelationType.Annotation;
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IImageInfo {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_info?: {
|
||||
// eslint-disable-line camelcase
|
||||
w?: number;
|
||||
h?: number;
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
};
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export enum Visibility {
|
||||
Public = "public",
|
||||
Private = "private",
|
||||
@@ -59,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",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
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 { RoomType } from "./event";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "./partials";
|
||||
import { ImageInfo } from "./media";
|
||||
import { PolicyRecommendation } from "../models/invites-ignorer";
|
||||
|
||||
export interface RoomCanonicalAliasEventContent {
|
||||
alias?: string;
|
||||
alt_aliases?: string[];
|
||||
}
|
||||
|
||||
export interface RoomCreateEventContent {
|
||||
"creator"?: string;
|
||||
"m.federate"?: boolean;
|
||||
"predecessor"?: {
|
||||
event_id: string;
|
||||
room_id: string;
|
||||
};
|
||||
"room_version"?: string;
|
||||
"type"?: RoomType;
|
||||
}
|
||||
|
||||
export interface RoomJoinRulesEventContent {
|
||||
join_rule: JoinRule;
|
||||
allow?: {
|
||||
room_id: string;
|
||||
type: RestrictedAllowType;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface RoomMemberEventContent {
|
||||
avatar_url?: string;
|
||||
displayname?: string;
|
||||
is_direct?: boolean;
|
||||
join_authorised_via_users_server?: string;
|
||||
membership: "invite" | "join" | "knock" | "leave" | "ban";
|
||||
reason?: string;
|
||||
third_party_invite?: {
|
||||
display_name: string;
|
||||
signed: {
|
||||
mxid: string;
|
||||
token: string;
|
||||
ts: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoomThirdPartyInviteEventContent {
|
||||
display_name: string;
|
||||
key_validity_url: string;
|
||||
public_key: string;
|
||||
public_keys: {
|
||||
key_validity_url?: string;
|
||||
public_key: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface RoomPowerLevelsEventContent {
|
||||
ban?: number;
|
||||
events?: { [eventType: string]: number };
|
||||
events_default?: number;
|
||||
invite?: number;
|
||||
kick?: number;
|
||||
notifications?: {
|
||||
room?: number;
|
||||
};
|
||||
redact?: number;
|
||||
state_default?: number;
|
||||
users?: { [userId: string]: number };
|
||||
users_default?: number;
|
||||
}
|
||||
|
||||
export interface RoomNameEventContent {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RoomTopicEventContent {
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface RoomAvatarEventContent {
|
||||
url?: string;
|
||||
info?: ImageInfo;
|
||||
}
|
||||
|
||||
export interface RoomPinnedEventsEventContent {
|
||||
pinned: string[];
|
||||
}
|
||||
|
||||
export interface RoomEncryptionEventContent {
|
||||
algorithm: "m.megolm.v1.aes-sha2";
|
||||
rotation_period_ms?: number;
|
||||
rotation_period_msgs?: number;
|
||||
}
|
||||
|
||||
export interface RoomHistoryVisibilityEventContent {
|
||||
history_visibility: HistoryVisibility;
|
||||
}
|
||||
|
||||
export interface RoomGuestAccessEventContent {
|
||||
guest_access: GuestAccess;
|
||||
}
|
||||
|
||||
export interface RoomServerAclEventContent {
|
||||
allow?: string[];
|
||||
allow_ip_literals?: boolean;
|
||||
deny?: string[];
|
||||
}
|
||||
|
||||
export interface RoomTombstoneEventContent {
|
||||
body: string;
|
||||
replacement_room: string;
|
||||
}
|
||||
|
||||
export interface SpaceChildEventContent {
|
||||
order?: string;
|
||||
suggested?: boolean;
|
||||
via?: string[];
|
||||
}
|
||||
|
||||
export interface SpaceParentEventContent {
|
||||
canonical?: boolean;
|
||||
via?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyRuleEventContent {
|
||||
entity: string;
|
||||
reason: string;
|
||||
recommendation: PolicyRecommendation;
|
||||
}
|
||||
+2
-2
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IAuthDict, IAuthData } from "../interactive-auth";
|
||||
import { AuthDict, IAuthData } from "../interactive-auth";
|
||||
|
||||
/**
|
||||
* Helper type to represent HTTP request body for a UIA enabled endpoint
|
||||
*/
|
||||
export type UIARequest<T> = T & {
|
||||
auth?: IAuthDict;
|
||||
auth?: AuthDict;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+116
-151
@@ -35,14 +35,14 @@ import {
|
||||
import { StubStore } from "./store/stub";
|
||||
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
|
||||
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
|
||||
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
|
||||
import { CallEventHandler, CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
|
||||
import {
|
||||
GroupCallEventHandler,
|
||||
GroupCallEventHandlerEvent,
|
||||
GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler";
|
||||
import * as utils from "./utils";
|
||||
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
|
||||
import { noUnsafeEventProps, QueryDict, replaceParam, safeSet, sleep } from "./utils";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
import { IActionsObject, PushProcessor } from "./pushprocessor";
|
||||
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
|
||||
@@ -64,12 +64,12 @@ import {
|
||||
IdentityPrefix,
|
||||
IHttpOpts,
|
||||
IRequestOpts,
|
||||
TokenRefreshFunction,
|
||||
MatrixError,
|
||||
MatrixHttpApi,
|
||||
MediaPrefix,
|
||||
Method,
|
||||
retryNetworkOperation,
|
||||
TokenRefreshFunction,
|
||||
Upload,
|
||||
UploadOpts,
|
||||
UploadResponse,
|
||||
@@ -83,7 +83,6 @@ import {
|
||||
ICryptoCallbacks,
|
||||
IRoomKeyRequestBody,
|
||||
isCryptoAvailable,
|
||||
VerificationMethod,
|
||||
} from "./crypto";
|
||||
import { DeviceInfo } from "./crypto/deviceinfo";
|
||||
import { decodeRecoveryKey } from "./crypto/recoverykey";
|
||||
@@ -145,11 +144,13 @@ import {
|
||||
RelationType,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
StateEvents,
|
||||
TimelineEvents,
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
} from "./@types/event";
|
||||
import { IdServerUnbindResult, IImageInfo, JoinRule, Preset, Visibility } from "./@types/partials";
|
||||
import { GuestAccess, HistoryVisibility, IdServerUnbindResult, JoinRule, Preset, Visibility } from "./@types/partials";
|
||||
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
|
||||
import { randomString } from "./randomstring";
|
||||
import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
|
||||
@@ -223,6 +224,8 @@ import { RegisterRequest, RegisterResponse } from "./@types/registration";
|
||||
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
|
||||
import { getRelationsThreadFilter } from "./thread-utils";
|
||||
import { KnownMembership, Membership } from "./@types/membership";
|
||||
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
|
||||
import { ImageInfo } from "./@types/media";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -369,8 +372,10 @@ export interface ICreateClientOpts {
|
||||
* Verification methods we should offer to the other side when performing an interactive verification.
|
||||
* If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS
|
||||
* (aka "emojis").
|
||||
*
|
||||
* See {@link types.VerificationMethod} for a set of useful constants for this parameter.
|
||||
*/
|
||||
verificationMethods?: Array<VerificationMethod>;
|
||||
verificationMethods?: Array<string>;
|
||||
|
||||
/**
|
||||
* Whether relaying calls through a TURN server should be forced. Default false.
|
||||
@@ -499,11 +504,6 @@ export interface IStartClientOpts {
|
||||
*/
|
||||
clientWellKnownPollPeriod?: number;
|
||||
|
||||
/**
|
||||
* @deprecated use `threadSupport` instead
|
||||
*/
|
||||
experimentalThreadSupport?: boolean;
|
||||
|
||||
/**
|
||||
* Will organises events in threaded conversations when
|
||||
* a thread relation is encountered
|
||||
@@ -865,10 +865,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 {
|
||||
@@ -1262,7 +1276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise<Room>; errorTs?: number } } = {};
|
||||
protected notifTimelineSet: EventTimelineSet | null = null;
|
||||
protected cryptoStore?: CryptoStore;
|
||||
protected verificationMethods?: VerificationMethod[];
|
||||
protected verificationMethods?: string[];
|
||||
protected fallbackICEServerAllowed = false;
|
||||
protected syncApi?: SlidingSyncSdk | SyncApi;
|
||||
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
|
||||
@@ -1437,55 +1451,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
fixNotificationCountOnDecryption(this, event);
|
||||
});
|
||||
|
||||
// Like above, we have to listen for read receipts from ourselves in order to
|
||||
// correctly handle notification counts on encrypted rooms.
|
||||
// This fixes https://github.com/vector-im/element-web/issues/9421
|
||||
this.on(RoomEvent.Receipt, (event, room) => {
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
// Figure out if we've read something or if it's just informational
|
||||
const content = event.getContent();
|
||||
const isSelf =
|
||||
Object.keys(content).filter((eid) => {
|
||||
for (const [key, value] of Object.entries(content[eid])) {
|
||||
if (!utils.isSupportedReceiptType(key)) continue;
|
||||
if (!value) continue;
|
||||
|
||||
if (Object.keys(value).includes(this.getUserId()!)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).length > 0;
|
||||
|
||||
if (!isSelf) return;
|
||||
|
||||
// Work backwards to determine how many events are unread. We also set
|
||||
// a limit for how back we'll look to avoid spinning CPU for too long.
|
||||
// If we hit the limit, we assume the count is unchanged.
|
||||
const maxHistory = 20;
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
|
||||
let highlightCount = 0;
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (i === events.length - maxHistory) return; // limit reached
|
||||
|
||||
const event = events[i];
|
||||
|
||||
if (room.hasUserReadEvent(this.getUserId()!, event.getId()!)) {
|
||||
// If the user has read the event, then the counting is done.
|
||||
break;
|
||||
}
|
||||
|
||||
const pushActions = this.getPushActionsForEvent(event);
|
||||
highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
|
||||
}
|
||||
|
||||
// Note: we don't need to handle 'total' notifications because the counts
|
||||
// will come from the server.
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
|
||||
}
|
||||
});
|
||||
|
||||
this.ignoredInvites = new IgnoredInvites(this);
|
||||
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
|
||||
|
||||
@@ -1575,19 +1540,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
}
|
||||
|
||||
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
|
||||
this.logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
|
||||
}
|
||||
|
||||
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
|
||||
// We should fallback to that value for backwards compatibility purposes
|
||||
if (
|
||||
!this.clientOpts.hasOwnProperty("threadSupport") &&
|
||||
this.clientOpts.hasOwnProperty("experimentalThreadSupport")
|
||||
) {
|
||||
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
|
||||
}
|
||||
|
||||
this.syncApi.sync().catch((e) => this.logger.info("Sync startup aborted with an error:", e));
|
||||
|
||||
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
|
||||
@@ -2817,11 +2769,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
// deprecated: use requestVerification instead
|
||||
public legacyDeviceVerification(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
method: VerificationMethod,
|
||||
): Promise<VerificationRequest> {
|
||||
public legacyDeviceVerification(userId: string, deviceId: string, method: string): Promise<VerificationRequest> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
@@ -4549,7 +4497,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param roomId - the room to update power levels in
|
||||
* @param userId - the ID of the user or users to update power levels of
|
||||
* @param powerLevel - the numeric power level to update given users to
|
||||
* @param event - deprecated and no longer used.
|
||||
* @returns Promise which resolves: to an ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
@@ -4557,10 +4504,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
roomId: string,
|
||||
userId: string | string[],
|
||||
powerLevel: number | undefined,
|
||||
/**
|
||||
* @deprecated no longer needed, unused.
|
||||
*/
|
||||
event?: MatrixEvent | null,
|
||||
): Promise<ISendEventResponse> {
|
||||
let content: IPowerLevelsContent | undefined;
|
||||
if (this.clientRunning && this.isInitialSyncComplete()) {
|
||||
@@ -4624,12 +4567,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
|
||||
}
|
||||
|
||||
public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
|
||||
public sendEvent(
|
||||
public sendEvent<K extends keyof TimelineEvents>(
|
||||
roomId: string,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K],
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendEvent<K extends keyof TimelineEvents>(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventType: string,
|
||||
content: IContent,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K],
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendEvent(
|
||||
@@ -4928,10 +4876,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 {
|
||||
@@ -5016,27 +4964,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public sendMessage(roomId: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
|
||||
public sendMessage(roomId: string, content: RoomMessageEventContent, txnId?: string): Promise<ISendEventResponse>;
|
||||
public sendMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
content: IContent,
|
||||
content: RoomMessageEventContent,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendMessage(
|
||||
roomId: string,
|
||||
threadId: string | null | IContent,
|
||||
content?: IContent | string,
|
||||
threadId: string | null | RoomMessageEventContent,
|
||||
content?: RoomMessageEventContent | string,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (typeof threadId !== "string" && threadId !== null) {
|
||||
txnId = content as string;
|
||||
content = threadId as IContent;
|
||||
content = threadId as RoomMessageEventContent;
|
||||
threadId = null;
|
||||
}
|
||||
|
||||
const eventType: string = EventType.RoomMessage;
|
||||
const sendContent: IContent = content as IContent;
|
||||
const eventType = EventType.RoomMessage;
|
||||
const sendContent = content as RoomMessageEventContent;
|
||||
|
||||
return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
|
||||
}
|
||||
@@ -5126,33 +5074,33 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to a ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise<ISendEventResponse>;
|
||||
public sendImageMessage(roomId: string, url: string, info?: ImageInfo, text?: string): Promise<ISendEventResponse>;
|
||||
public sendImageMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url: string,
|
||||
info?: IImageInfo,
|
||||
info?: ImageInfo,
|
||||
text?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendImageMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url?: string | IImageInfo,
|
||||
info?: IImageInfo | string,
|
||||
url?: string | ImageInfo,
|
||||
info?: ImageInfo | string,
|
||||
text = "Image",
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
|
||||
text = (info as string) || "Image";
|
||||
info = url as IImageInfo;
|
||||
info = url as ImageInfo;
|
||||
url = threadId as string;
|
||||
threadId = null;
|
||||
}
|
||||
const content = {
|
||||
msgtype: MsgType.Image,
|
||||
url: url,
|
||||
info: info,
|
||||
url: url as string,
|
||||
info: info as ImageInfo,
|
||||
body: text,
|
||||
};
|
||||
} satisfies RoomMessageEventContent;
|
||||
return this.sendMessage(roomId, threadId, content);
|
||||
}
|
||||
|
||||
@@ -5163,34 +5111,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public sendStickerMessage(
|
||||
roomId: string,
|
||||
url: string,
|
||||
info?: IImageInfo,
|
||||
info?: ImageInfo,
|
||||
text?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendStickerMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url: string,
|
||||
info?: IImageInfo,
|
||||
info?: ImageInfo,
|
||||
text?: string,
|
||||
): Promise<ISendEventResponse>;
|
||||
public sendStickerMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url?: string | IImageInfo,
|
||||
info?: IImageInfo | string,
|
||||
url?: string | ImageInfo,
|
||||
info?: ImageInfo | string,
|
||||
text = "Sticker",
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
|
||||
text = (info as string) || "Sticker";
|
||||
info = url as IImageInfo;
|
||||
info = url as ImageInfo;
|
||||
url = threadId as string;
|
||||
threadId = null;
|
||||
}
|
||||
const content = {
|
||||
url: url,
|
||||
info: info,
|
||||
url: url as string,
|
||||
info: info as ImageInfo,
|
||||
body: text,
|
||||
};
|
||||
} satisfies StickerEventContent;
|
||||
|
||||
return this.sendEvent(roomId, threadId, EventType.Sticker, content);
|
||||
}
|
||||
@@ -5827,7 +5775,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(
|
||||
@@ -5837,8 +5790,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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6704,7 +6667,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
roomId,
|
||||
EventType.RoomGuestAccess,
|
||||
{
|
||||
guest_access: opts.allowJoin ? "can_join" : "forbidden",
|
||||
guest_access: opts.allowJoin ? GuestAccess.CanJoin : GuestAccess.Forbidden,
|
||||
},
|
||||
"",
|
||||
);
|
||||
@@ -6715,7 +6678,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
roomId,
|
||||
EventType.RoomHistoryVisibility,
|
||||
{
|
||||
history_visibility: "world_readable",
|
||||
history_visibility: HistoryVisibility.WorldReadable,
|
||||
},
|
||||
"",
|
||||
);
|
||||
@@ -8386,14 +8349,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a state event into a room
|
||||
* @param roomId - ID of the room to send the event into
|
||||
* @param eventType - type of the state event to send
|
||||
* @param content - content of the event to send
|
||||
* @param stateKey - the stateKey to send into the room
|
||||
* @param opts - Options for the request function.
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public sendStateEvent(
|
||||
public sendStateEvent<K extends keyof StateEvents>(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: IContent,
|
||||
eventType: K,
|
||||
content: StateEvents[K],
|
||||
stateKey = "",
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<ISendEventResponse> {
|
||||
@@ -8406,7 +8374,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (stateKey !== undefined) {
|
||||
path = utils.encodeUri(path + "/$stateKey", pathParams);
|
||||
}
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content, opts);
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content as Body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8492,11 +8460,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
since,
|
||||
...options
|
||||
}: IRoomDirectoryOptions = {}): Promise<IPublicRoomsResponse> {
|
||||
const queryParams: QueryDict = { server, limit, since };
|
||||
if (Object.keys(options).length === 0) {
|
||||
const queryParams: QueryDict = { server, limit, since };
|
||||
return this.http.authedRequest(Method.Get, "/publicRooms", queryParams);
|
||||
} else {
|
||||
return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options);
|
||||
const queryParams: QueryDict = { server };
|
||||
const body = {
|
||||
limit,
|
||||
since,
|
||||
...options,
|
||||
};
|
||||
return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8558,17 +8532,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Get, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Promise which resolves: Object with room_id and servers.
|
||||
* @returns Rejects: with an error response.
|
||||
* @deprecated use `getRoomIdForAlias` instead
|
||||
*/
|
||||
// eslint-disable-next-line camelcase
|
||||
public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
|
||||
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
|
||||
return this.http.request(Method.Get, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visibility of a room in the current HS's room directory
|
||||
* @returns Promise which resolves: TODO
|
||||
@@ -8582,7 +8545,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the visbility of a room in the current HS's room directory
|
||||
* Set the visibility of a room in the current HS's room directory
|
||||
* @param visibility - "public" to make the room visible
|
||||
* in the public directory, or "private" to make
|
||||
* it invisible.
|
||||
@@ -9829,14 +9792,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use supportsThreads() instead
|
||||
*/
|
||||
public supportsExperimentalThreads(): boolean {
|
||||
this.logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
|
||||
return this.clientOpts?.experimentalThreadSupport || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper to determine thread support
|
||||
* @returns a boolean to determine if threads are enabled
|
||||
@@ -9860,11 +9815,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { IClearEvent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { CryptoApi, DecryptionFailureCode, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
@@ -226,10 +226,6 @@ export interface EventDecryptionResult {
|
||||
* restored from backup)
|
||||
*/
|
||||
untrusted?: boolean;
|
||||
/**
|
||||
* The sender doesn't authorize the unverified devices to decrypt his messages
|
||||
*/
|
||||
encryptedDisabledForUnverifiedDevices?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,3 +259,43 @@ export interface BackupDecryptor {
|
||||
*/
|
||||
free(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when decryption fails
|
||||
*
|
||||
* @param code - Reason code for the failure.
|
||||
*
|
||||
* @param msg - user-visible message describing the problem
|
||||
*
|
||||
* @param details - key/value pairs reported in the logs but not shown
|
||||
* to the user.
|
||||
*/
|
||||
export class DecryptionError extends Error {
|
||||
public readonly detailedString: string;
|
||||
|
||||
public constructor(
|
||||
public readonly code: DecryptionFailureCode,
|
||||
msg: string,
|
||||
details?: Record<string, string | Error>,
|
||||
) {
|
||||
super(msg);
|
||||
this.name = "DecryptionError";
|
||||
this.detailedString = detailedStringForDecryptionError(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
|
||||
let result = err.name + "[msg: " + err.message;
|
||||
|
||||
if (details) {
|
||||
result +=
|
||||
", " +
|
||||
Object.keys(details)
|
||||
.map((k) => k + ": " + details[k])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
result += "]";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
LegacyLocationEventContent,
|
||||
} from "./@types/location";
|
||||
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
|
||||
import { IContent } from "./models/event";
|
||||
import { RoomMessageEventContent } from "./@types/events";
|
||||
|
||||
/**
|
||||
* Generates the content for a HTML Message event
|
||||
@@ -38,7 +38,7 @@ import { IContent } from "./models/event";
|
||||
* @param htmlBody - the HTML representation of the message
|
||||
* @returns
|
||||
*/
|
||||
export function makeHtmlMessage(body: string, htmlBody: string): IContent {
|
||||
export function makeHtmlMessage(body: string, htmlBody: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Text,
|
||||
format: "org.matrix.custom.html",
|
||||
@@ -53,7 +53,7 @@ export function makeHtmlMessage(body: string, htmlBody: string): IContent {
|
||||
* @param htmlBody - the HTML representation of the notice
|
||||
* @returns
|
||||
*/
|
||||
export function makeHtmlNotice(body: string, htmlBody: string): IContent {
|
||||
export function makeHtmlNotice(body: string, htmlBody: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Notice,
|
||||
format: "org.matrix.custom.html",
|
||||
@@ -68,7 +68,7 @@ export function makeHtmlNotice(body: string, htmlBody: string): IContent {
|
||||
* @param htmlBody - the HTML representation of the emote
|
||||
* @returns
|
||||
*/
|
||||
export function makeHtmlEmote(body: string, htmlBody: string): IContent {
|
||||
export function makeHtmlEmote(body: string, htmlBody: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Emote,
|
||||
format: "org.matrix.custom.html",
|
||||
@@ -82,7 +82,7 @@ export function makeHtmlEmote(body: string, htmlBody: string): IContent {
|
||||
* @param body - the plaintext body of the emote
|
||||
* @returns
|
||||
*/
|
||||
export function makeTextMessage(body: string): IContent {
|
||||
export function makeTextMessage(body: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Text,
|
||||
body: body,
|
||||
@@ -94,7 +94,7 @@ export function makeTextMessage(body: string): IContent {
|
||||
* @param body - the plaintext body of the notice
|
||||
* @returns
|
||||
*/
|
||||
export function makeNotice(body: string): IContent {
|
||||
export function makeNotice(body: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Notice,
|
||||
body: body,
|
||||
@@ -106,7 +106,7 @@ export function makeNotice(body: string): IContent {
|
||||
* @param body - the plaintext body of the emote
|
||||
* @returns
|
||||
*/
|
||||
export function makeEmoteMessage(body: string): IContent {
|
||||
export function makeEmoteMessage(body: string): RoomMessageEventContent {
|
||||
return {
|
||||
msgtype: MsgType.Emote,
|
||||
body: body,
|
||||
|
||||
+29
-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,23 @@ export function getHttpUriForMxc(
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
if (useAuthentication) {
|
||||
allowRedirects = true; // per docs (MSC3916 always expects redirects)
|
||||
|
||||
// Dev note: MSC3916 (as of writing) 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) {
|
||||
// TODO: Use stable once available (requires FCP on MSC3916).
|
||||
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/download/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/download/";
|
||||
}
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (width) {
|
||||
@@ -68,7 +89,12 @@ 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) {
|
||||
// TODO: Use stable once available (requires FCP on MSC3916).
|
||||
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/thumbnail/";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof allowRedirects === "boolean") {
|
||||
|
||||
@@ -496,6 +496,116 @@ 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>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
export enum DecryptionFailureCode {
|
||||
/** Message was encrypted with a Megolm session whose keys have not been shared with us. */
|
||||
MEGOLM_UNKNOWN_INBOUND_SESSION_ID = "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
|
||||
/** 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",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ROOM = "OLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER = "OLM_BAD_SENDER",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,11 +18,12 @@ import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility }
|
||||
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
|
||||
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
|
||||
import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { DecryptionFailureCode } from "../crypto-api";
|
||||
import { DecryptionError } from "../common-crypto/CryptoBackend";
|
||||
|
||||
// The maximum size of an event is 65K, and we base64 the content, so this is a
|
||||
// reasonable approximation to the biggest plaintext we can encrypt.
|
||||
@@ -1220,8 +1221,8 @@ export class OlmDevice {
|
||||
this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => {
|
||||
if (session === null || sessionData === null) {
|
||||
if (withheld) {
|
||||
error = new algorithms.DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
error = new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
calculateWithheldMessage(withheld),
|
||||
{
|
||||
session: senderKey + "|" + sessionId,
|
||||
@@ -1236,8 +1237,8 @@ export class OlmDevice {
|
||||
res = session.decrypt(body);
|
||||
} catch (e) {
|
||||
if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) {
|
||||
error = new algorithms.DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
error = new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
calculateWithheldMessage(withheld),
|
||||
{
|
||||
session: senderKey + "|" + sessionId,
|
||||
|
||||
@@ -199,45 +199,6 @@ export abstract class DecryptionAlgorithm {
|
||||
public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when decryption fails
|
||||
*
|
||||
* @param msg - user-visible message describing the problem
|
||||
*
|
||||
* @param details - key/value pairs reported in the logs but not shown
|
||||
* to the user.
|
||||
*/
|
||||
export class DecryptionError extends Error {
|
||||
public readonly detailedString: string;
|
||||
|
||||
public constructor(
|
||||
public readonly code: string,
|
||||
msg: string,
|
||||
details?: Record<string, string | Error>,
|
||||
) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
this.name = "DecryptionError";
|
||||
this.detailedString = detailedStringForDecryptionError(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
|
||||
let result = err.name + "[msg: " + err.message;
|
||||
|
||||
if (details) {
|
||||
result +=
|
||||
", " +
|
||||
Object.keys(details)
|
||||
.map((k) => k + ": " + details[k])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
result += "]";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class UnknownDeviceError extends Error {
|
||||
/**
|
||||
* Exception thrown specifically when we want to warn the user to consider
|
||||
@@ -274,3 +235,6 @@ export function registerAlgorithm<P extends IParams = IParams>(
|
||||
ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm);
|
||||
DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm);
|
||||
}
|
||||
|
||||
/* Re-export for backwards compatibility. Deprecated: this is an internal class. */
|
||||
export { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
||||
@@ -26,7 +26,6 @@ import * as olmlib from "../olmlib";
|
||||
import {
|
||||
DecryptionAlgorithm,
|
||||
DecryptionClassParams,
|
||||
DecryptionError,
|
||||
EncryptionAlgorithm,
|
||||
IParams,
|
||||
registerAlgorithm,
|
||||
@@ -45,6 +44,8 @@ import { OlmGroupSessionExtraData } from "../../@types/crypto";
|
||||
import { MatrixError } from "../../http-api";
|
||||
import { immediate, MapWithDefault } from "../../utils";
|
||||
import { KnownMembership } from "../../@types/membership";
|
||||
import { DecryptionFailureCode } from "../../crypto-api";
|
||||
import { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
||||
// determine whether the key can be shared with invitees
|
||||
export function isRoomSharedHistory(room: Room): boolean {
|
||||
@@ -1313,7 +1314,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
const content = event.getWireContent();
|
||||
|
||||
if (!content.sender_key || !content.session_id || !content.ciphertext) {
|
||||
throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input");
|
||||
throw new DecryptionError(DecryptionFailureCode.MEGOLM_MISSING_FIELDS, "Missing fields in input");
|
||||
}
|
||||
|
||||
// we add the event to the pending list *before* we start decryption.
|
||||
@@ -1339,12 +1340,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
throw e;
|
||||
}
|
||||
|
||||
let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
|
||||
let errorCode = DecryptionFailureCode.OLM_DECRYPT_GROUP_MESSAGE_ERROR;
|
||||
|
||||
if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") {
|
||||
this.requestKeysForEvent(event);
|
||||
|
||||
errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
|
||||
errorCode = DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX;
|
||||
}
|
||||
|
||||
throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
|
||||
@@ -1377,13 +1378,13 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
if (problem.fixed) {
|
||||
problemDescription += " Trying to create a new secure channel and re-requesting the keys.";
|
||||
}
|
||||
throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, {
|
||||
throw new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, problemDescription, {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
throw new DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
@@ -1405,7 +1406,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
// (this is somewhat redundant, since the megolm session is scoped to the
|
||||
// room, so neither the sender nor a MITM can lie about the room_id).
|
||||
if (payload.room_id !== event.getRoomId()) {
|
||||
throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id);
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_BAD_ROOM,
|
||||
"Message intended for room " + payload.room_id,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,11 +22,13 @@ import type { IEventDecryptionResult } from "../../@types/crypto";
|
||||
import { logger } from "../../logger";
|
||||
import * as olmlib from "../olmlib";
|
||||
import { DeviceInfo } from "../deviceinfo";
|
||||
import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base";
|
||||
import { DecryptionAlgorithm, EncryptionAlgorithm, registerAlgorithm } from "./base";
|
||||
import { Room } from "../../models/room";
|
||||
import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { IEncryptedContent, IOlmEncryptedContent } from "../index";
|
||||
import { IInboundSession } from "../OlmDevice";
|
||||
import { DecryptionFailureCode } from "../../crypto-api";
|
||||
import { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -159,11 +161,14 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
const ciphertext = content.ciphertext;
|
||||
|
||||
if (!ciphertext) {
|
||||
throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
|
||||
throw new DecryptionError(DecryptionFailureCode.OLM_MISSING_CIPHERTEXT, "Missing ciphertext");
|
||||
}
|
||||
|
||||
if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) {
|
||||
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_NOT_INCLUDED_IN_RECIPIENTS,
|
||||
"Not included in recipients",
|
||||
);
|
||||
}
|
||||
const message = ciphertext[this.olmDevice.deviceCurve25519Key!];
|
||||
let payloadString: string;
|
||||
@@ -171,7 +176,7 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
try {
|
||||
payloadString = await this.decryptMessage(deviceKey, message);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
|
||||
throw new DecryptionError(DecryptionFailureCode.OLM_BAD_ENCRYPTED_MESSAGE, "Bad Encrypted Message", {
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
@@ -182,14 +187,21 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// check that we were the intended recipient, to avoid unknown-key attack
|
||||
// https://github.com/vector-im/vector-web/issues/2483
|
||||
if (payload.recipient != this.userId) {
|
||||
throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_RECIPIENT,
|
||||
"Message was intended for " + payload.recipient,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
|
||||
throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", {
|
||||
intended: payload.recipient_keys.ed25519,
|
||||
our_key: this.olmDevice.deviceEd25519Key!,
|
||||
});
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_RECIPIENT_KEY,
|
||||
"Message not intended for this device",
|
||||
{
|
||||
intended: payload.recipient_keys.ed25519,
|
||||
our_key: this.olmDevice.deviceEd25519Key!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
@@ -216,18 +228,26 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
try {
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER_CHECK_FAILED", "Could not verify sender identity", {
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_SENDER_CHECK_FAILED,
|
||||
"Could not verify sender identity",
|
||||
{
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
}
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
|
||||
real_sender: senderKeyUser,
|
||||
});
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_SENDER,
|
||||
"Message claimed to be from " + event.getSender(),
|
||||
{
|
||||
real_sender: senderKeyUser,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// check that the original sender matches what the homeserver told us, to
|
||||
@@ -235,16 +255,24 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// (this check is also provided via the sender's embedded ed25519 key,
|
||||
// which is checked elsewhere).
|
||||
if (payload.sender != event.getSender()) {
|
||||
throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, {
|
||||
reported_sender: event.getSender()!,
|
||||
});
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_FORWARDED_MESSAGE,
|
||||
"Message forwarded from " + payload.sender,
|
||||
{
|
||||
reported_sender: event.getSender()!,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Olm events intended for a room have a room_id.
|
||||
if (payload.room_id !== event.getRoomId()) {
|
||||
throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, {
|
||||
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
|
||||
});
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_ROOM,
|
||||
"Message intended for room " + payload.room_id,
|
||||
{
|
||||
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const claimedKeys = payload.keys || {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+20
-4
@@ -73,7 +73,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import {
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DecryptionFailureCode,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
@@ -97,8 +98,8 @@ import {
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
OwnDeviceKeys,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
@@ -4209,8 +4210,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
|
||||
if (!AlgClass) {
|
||||
throw new algorithms.DecryptionError(
|
||||
"UNKNOWN_ENCRYPTION_ALGORITHM",
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.UNKNOWN_ENCRYPTION_ALGORITHM,
|
||||
'Unknown encryption algorithm "' + algorithm + '".',
|
||||
);
|
||||
}
|
||||
@@ -4286,6 +4287,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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,17 @@ import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -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();
|
||||
|
||||
@@ -28,9 +28,10 @@ import { MatrixClient } from "../../client";
|
||||
import { IVerificationChannel } from "./request/Channel";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
import { VerificationMethod } from "../../types";
|
||||
|
||||
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
|
||||
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
|
||||
export const SHOW_QR_CODE_METHOD = VerificationMethod.ShowQrCode;
|
||||
export const SCAN_QR_CODE_METHOD = VerificationMethod.ScanQrCode;
|
||||
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type QrCodeEvent = VerifierEvent;
|
||||
@@ -73,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);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { generateDecimalSas } from "./SASDecimal";
|
||||
import { EventType } from "../../@types/event";
|
||||
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
import { VerificationMethod } from "../../types";
|
||||
|
||||
// backwards-compatibility exports
|
||||
export type {
|
||||
@@ -233,7 +234,7 @@ export class SAS extends Base {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public static get NAME(): string {
|
||||
return "m.sas.v1";
|
||||
return VerificationMethod.Sas;
|
||||
}
|
||||
|
||||
public get events(): string[] {
|
||||
@@ -307,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);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest";
|
||||
import { logger } from "../../../logger";
|
||||
import { IVerificationChannel } from "./Channel";
|
||||
import { EventType } from "../../../@types/event";
|
||||
import { EventType, TimelineEvents } from "../../../@types/event";
|
||||
import { MatrixClient } from "../../../client";
|
||||
import { MatrixEvent } from "../../../models/event";
|
||||
import { IRequestsMap } from "../..";
|
||||
@@ -299,7 +299,11 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
if (type === REQUEST_TYPE) {
|
||||
sendType = MESSAGE_TYPE;
|
||||
}
|
||||
const response = await this.client.sendEvent(this.roomId, sendType, content);
|
||||
const response = await this.client.sendEvent(
|
||||
this.roomId,
|
||||
sendType as keyof TimelineEvents,
|
||||
content as TimelineEvents[keyof TimelineEvents],
|
||||
);
|
||||
if (type === REQUEST_TYPE) {
|
||||
this.requestEventId = response.event_id;
|
||||
}
|
||||
|
||||
@@ -14,25 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export enum InvalidStoreState {
|
||||
ToggledLazyLoading,
|
||||
}
|
||||
|
||||
export class InvalidStoreError extends Error {
|
||||
public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
|
||||
|
||||
public constructor(
|
||||
public readonly reason: InvalidStoreState,
|
||||
public readonly value: any,
|
||||
) {
|
||||
const message =
|
||||
`Store is invalid because ${reason}, ` +
|
||||
`please stop the client, delete all data and start the client again`;
|
||||
super(message);
|
||||
this.name = "InvalidStoreError";
|
||||
}
|
||||
}
|
||||
|
||||
export enum InvalidCryptoStoreState {
|
||||
TooNew = "TOO_NEW",
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ export enum Method {
|
||||
Put = "PUT",
|
||||
Post = "POST",
|
||||
Delete = "DELETE",
|
||||
Options = "OPTIONS",
|
||||
Head = "HEAD",
|
||||
Patch = "PATCH",
|
||||
}
|
||||
|
||||
+5
-11
@@ -140,12 +140,6 @@ export type AuthDict =
|
||||
| { type: Exclude<string, AuthType>; [key: string]: any }
|
||||
| {};
|
||||
|
||||
/**
|
||||
* Backwards compatible export
|
||||
* @deprecated in favour of AuthDict
|
||||
*/
|
||||
export type IAuthDict = AuthDict;
|
||||
|
||||
export class NoAuthFlowFoundError extends Error {
|
||||
public name = "NoAuthFlowFoundError";
|
||||
|
||||
@@ -168,7 +162,7 @@ export class NoAuthFlowFoundError extends Error {
|
||||
*
|
||||
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
|
||||
*/
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: AuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
|
||||
interface IOpts<T> {
|
||||
/**
|
||||
@@ -340,7 +334,7 @@ export class InteractiveAuth<T> {
|
||||
// another just to check what the status is
|
||||
if (this.submitPromise) return;
|
||||
|
||||
let authDict: IAuthDict = {};
|
||||
let authDict: AuthDict = {};
|
||||
if (this.currentStage == EMAIL_STAGE_TYPE) {
|
||||
// The email can be validated out-of-band, but we need to provide the
|
||||
// creds so the HS can go & check it.
|
||||
@@ -410,7 +404,7 @@ export class InteractiveAuth<T> {
|
||||
* in the attemptAuth promise being rejected. This can be set to true
|
||||
* for requests that just poll to see if auth has been completed elsewhere.
|
||||
*/
|
||||
public async submitAuthDict(authData: IAuthDict, background = false): Promise<void> {
|
||||
public async submitAuthDict(authData: AuthDict, background = false): Promise<void> {
|
||||
if (!this.attemptAuthDeferred) {
|
||||
throw new Error("submitAuthDict() called before attemptAuth()");
|
||||
}
|
||||
@@ -431,7 +425,7 @@ export class InteractiveAuth<T> {
|
||||
}
|
||||
|
||||
// use the sessionid from the last request, if one is present.
|
||||
let auth: IAuthDict;
|
||||
let auth: AuthDict;
|
||||
if ((this.data as IAuthData)?.session) {
|
||||
auth = {
|
||||
session: (this.data as IAuthData).session,
|
||||
@@ -515,7 +509,7 @@ export class InteractiveAuth<T> {
|
||||
* This can be set to true for requests that just poll to see if auth has
|
||||
* been completed elsewhere.
|
||||
*/
|
||||
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
|
||||
private async doRequest(auth: AuthDict | null, background = false): Promise<void> {
|
||||
try {
|
||||
const result = await this.requestCallback(auth, background);
|
||||
this.attemptAuthDeferred!.resolve(result);
|
||||
|
||||
@@ -77,6 +77,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
|
||||
@@ -104,6 +105,10 @@ export { IdentityProviderBrand, SSOAction } from "./@types/auth";
|
||||
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth";
|
||||
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces";
|
||||
export { LocationAssetType } from "./@types/location";
|
||||
/**
|
||||
* @deprecated in favour of RoomJoinRulesEventContent on the types export
|
||||
*/
|
||||
export type { RoomJoinRulesEventContent as IJoinRuleEventContent } from "./@types/state_events";
|
||||
|
||||
/**
|
||||
* Types supporting cryptography.
|
||||
|
||||
@@ -136,7 +136,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
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;
|
||||
}
|
||||
if (!Array.isArray(eventMemberships)) {
|
||||
|
||||
@@ -15,12 +15,27 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
|
||||
import { RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
|
||||
import { IContent, MatrixEvent } from "./event";
|
||||
import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
|
||||
import { EventTimeline } from "./event-timeline";
|
||||
import { FileType } from "../http-api";
|
||||
import type { ISendEventResponse } from "../@types/requests";
|
||||
import { EncryptedFile } from "../@types/media";
|
||||
|
||||
export interface MSC3089EventContent {
|
||||
active?: boolean;
|
||||
name?: string;
|
||||
locked?: boolean;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface MSC3089EventContent {
|
||||
active?: boolean;
|
||||
name?: string;
|
||||
locked?: boolean;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
|
||||
@@ -131,7 +146,7 @@ export class MSC3089Branch {
|
||||
* Gets information about the file needed to download it.
|
||||
* @returns Information about the file.
|
||||
*/
|
||||
public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> {
|
||||
public async getFileInfo(): Promise<{ info: EncryptedFile; httpUrl: string }> {
|
||||
const event = await this.getFileEvent();
|
||||
|
||||
const file = event.getOriginalContent()["file"];
|
||||
@@ -179,7 +194,7 @@ export class MSC3089Branch {
|
||||
public async createNewVersion(
|
||||
name: string,
|
||||
encryptedContents: FileType,
|
||||
info: Partial<IEncryptedFile>,
|
||||
info: EncryptedFile,
|
||||
additionalContent?: IContent,
|
||||
): Promise<ISendEventResponse> {
|
||||
const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import promiseRetry from "p-retry";
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
|
||||
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
|
||||
import { Room } from "./room";
|
||||
import { logger } from "../logger";
|
||||
import { IContent, MatrixEvent } from "./event";
|
||||
@@ -34,6 +34,8 @@ import { isRoomSharedHistory } from "../crypto/algorithms/megolm";
|
||||
import { ISendEventResponse } from "../@types/requests";
|
||||
import { FileType } from "../http-api";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import { RoomPowerLevelsEventContent, SpaceChildEventContent } from "../@types/state_events";
|
||||
import { EncryptedFile, FileContent } from "../@types/media";
|
||||
|
||||
/**
|
||||
* The recommended defaults for a tree space's power levels. Note that this
|
||||
@@ -78,6 +80,12 @@ export enum TreePermissions {
|
||||
Owner = "owner", // "Admin" or PL100
|
||||
}
|
||||
|
||||
declare module "../@types/media" {
|
||||
interface FileContent {
|
||||
[UNSTABLE_MSC3089_LEAF.name]?: {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
|
||||
* file tree Space. Note that this is UNSTABLE and subject to breaking changes
|
||||
@@ -176,7 +184,7 @@ export class MSC3089TreeSpace {
|
||||
const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
||||
if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
|
||||
|
||||
const pls = currentPls?.getContent() || {};
|
||||
const pls = currentPls?.getContent<RoomPowerLevelsEventContent>() || {};
|
||||
const viewLevel = pls["users_default"] || 0;
|
||||
const editLevel = pls["events_default"] || 50;
|
||||
const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100;
|
||||
@@ -234,7 +242,7 @@ export class MSC3089TreeSpace {
|
||||
this.roomId,
|
||||
EventType.SpaceChild,
|
||||
{
|
||||
via: [this.client.getDomain()],
|
||||
via: [this.client.getDomain()!],
|
||||
},
|
||||
directory.roomId,
|
||||
);
|
||||
@@ -243,7 +251,7 @@ export class MSC3089TreeSpace {
|
||||
directory.roomId,
|
||||
EventType.SpaceParent,
|
||||
{
|
||||
via: [this.client.getDomain()],
|
||||
via: [this.client.getDomain()!],
|
||||
},
|
||||
this.roomId,
|
||||
);
|
||||
@@ -450,7 +458,9 @@ export class MSC3089TreeSpace {
|
||||
// XXX: We should be creating gaps to avoid conflicts
|
||||
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
|
||||
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
|
||||
const content = currentChild?.getContent<SpaceChildEventContent>() ?? {
|
||||
via: [this.client.getDomain()!],
|
||||
};
|
||||
await this.client.sendStateEvent(
|
||||
parentRoom.roomId,
|
||||
EventType.SpaceChild,
|
||||
@@ -473,7 +483,7 @@ export class MSC3089TreeSpace {
|
||||
|
||||
// Now we can finally update our own order state
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
|
||||
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
|
||||
const content = currentChild?.getContent<SpaceChildEventContent>() ?? { via: [this.client.getDomain()!] };
|
||||
await this.client.sendStateEvent(
|
||||
parentRoom.roomId,
|
||||
EventType.SpaceChild,
|
||||
@@ -499,7 +509,7 @@ export class MSC3089TreeSpace {
|
||||
public async createFile(
|
||||
name: string,
|
||||
encryptedContents: FileType,
|
||||
info: Partial<IEncryptedFile>,
|
||||
info: EncryptedFile,
|
||||
additionalContent?: IContent,
|
||||
): Promise<ISendEventResponse> {
|
||||
const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, {
|
||||
@@ -507,7 +517,7 @@ export class MSC3089TreeSpace {
|
||||
});
|
||||
info.url = mxc;
|
||||
|
||||
const fileContent = {
|
||||
const fileContent: FileContent = {
|
||||
msgtype: MsgType.File,
|
||||
body: name,
|
||||
url: mxc,
|
||||
@@ -526,7 +536,7 @@ export class MSC3089TreeSpace {
|
||||
...additionalContent,
|
||||
...fileContent,
|
||||
[UNSTABLE_MSC3089_LEAF.name]: {},
|
||||
});
|
||||
} as FileContent);
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId,
|
||||
|
||||
@@ -51,6 +51,9 @@ export class Device {
|
||||
/** display name of the device */
|
||||
public readonly displayName?: string;
|
||||
|
||||
/** whether the device is a dehydrated device */
|
||||
public readonly dehydrated: boolean = false;
|
||||
|
||||
public constructor(opts: DeviceParameters) {
|
||||
this.deviceId = opts.deviceId;
|
||||
this.userId = opts.userId;
|
||||
@@ -59,6 +62,7 @@ export class Device {
|
||||
this.verified = opts.verified || DeviceVerification.Unverified;
|
||||
this.signatures = opts.signatures || new Map();
|
||||
this.displayName = opts.displayName;
|
||||
this.dehydrated = !!opts.dehydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+73
-69
@@ -31,6 +31,7 @@ import {
|
||||
RelationType,
|
||||
ToDeviceMessageId,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
UNSIGNED_MEMBERSHIP_FIELD,
|
||||
} from "../@types/event";
|
||||
import { Crypto } from "../crypto";
|
||||
import { deepSortedObjectEntries, internaliseString } from "../utils";
|
||||
@@ -41,13 +42,13 @@ import { TypedReEmitter } from "../ReEmitter";
|
||||
import { MatrixError } from "../http-api";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { EventStatus } from "./event-status";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
import { CryptoBackend } from "../common-crypto/CryptoBackend";
|
||||
import { CryptoBackend, DecryptionError } from "../common-crypto/CryptoBackend";
|
||||
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
|
||||
import { IAnnotatedPushRule } from "../@types/PushRules";
|
||||
import { Room } from "./room";
|
||||
import { EventTimeline } from "./event-timeline";
|
||||
import { Membership } from "../@types/membership";
|
||||
import { DecryptionFailureCode } from "../crypto-api";
|
||||
|
||||
export { EventStatus } from "./event-status";
|
||||
|
||||
@@ -76,6 +77,7 @@ export interface IUnsigned {
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]?: Membership | string;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
@@ -227,7 +229,18 @@ export interface IMessageVisibilityHidden {
|
||||
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
|
||||
|
||||
export enum MatrixEventEvent {
|
||||
/**
|
||||
* An event has been decrypted, or we have failed to decrypt it.
|
||||
*
|
||||
* The payload consists of:
|
||||
*
|
||||
* * `event` - The {@link MatrixEvent} which we attempted to decrypt.
|
||||
*
|
||||
* * `err` - The error that occurred during decryption, or `undefined` if no error occurred.
|
||||
* Avoid use of this: {@link MatrixEvent.decryptionFailureReason} is more useful.
|
||||
*/
|
||||
Decrypted = "Event.decrypted",
|
||||
|
||||
BeforeRedaction = "Event.beforeRedaction",
|
||||
VisibilityChange = "Event.visibilityChange",
|
||||
LocalEventIdReplaced = "Event.localEventIdReplaced",
|
||||
@@ -239,12 +252,6 @@ export enum MatrixEventEvent {
|
||||
export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update;
|
||||
|
||||
export type MatrixEventHandlerMap = {
|
||||
/**
|
||||
* Fires when an event is decrypted
|
||||
*
|
||||
* @param event - The matrix event which has been decrypted
|
||||
* @param err - The error that occurred during decryption, or `undefined` if no error occurred.
|
||||
*/
|
||||
[MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void;
|
||||
[MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void;
|
||||
[MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void;
|
||||
@@ -274,6 +281,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
private _hasCachedExtEv = false;
|
||||
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
|
||||
|
||||
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
|
||||
private _decryptionFailureReason: DecryptionFailureCode | null = null;
|
||||
|
||||
/* curve25519 key which we believe belongs to the sender of the event. See
|
||||
* getSenderKey()
|
||||
*/
|
||||
@@ -713,6 +723,22 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
return this.event.state_key !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's room membership at the time the event was sent, as reported
|
||||
* by the server. This uses MSC4115.
|
||||
*
|
||||
* @returns The user's room membership, or `undefined` if the server does
|
||||
* not report it.
|
||||
*/
|
||||
public getMembershipAtEvent(): Membership | string | undefined {
|
||||
const unsigned = this.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_MEMBERSHIP_FIELD.name] === "string") {
|
||||
return unsigned[UNSIGNED_MEMBERSHIP_FIELD.name];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the content of this event with encrypted versions.
|
||||
* (This is used when sending an event; it should not be used by applications).
|
||||
@@ -771,7 +797,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* couldn't decrypt.
|
||||
*/
|
||||
public isDecryptionFailure(): boolean {
|
||||
return this.clearEvent?.content?.msgtype === "m.bad.encrypted";
|
||||
return this._decryptionFailureReason !== null;
|
||||
}
|
||||
|
||||
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
|
||||
public get decryptionFailureReason(): DecryptionFailureCode | null {
|
||||
return this._decryptionFailureReason;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -884,17 +915,14 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
while (true) {
|
||||
this.retryDecryption = false;
|
||||
|
||||
let res: IEventDecryptionResult;
|
||||
let err: Error | undefined = undefined;
|
||||
try {
|
||||
if (!crypto) {
|
||||
res = this.badEncryptedMessage("Encryption not enabled");
|
||||
} else {
|
||||
res = await crypto.decryptEvent(this);
|
||||
if (options.isRetry === true) {
|
||||
logger.info(`Decrypted event on retry (${this.getDetails()})`);
|
||||
}
|
||||
const res = await crypto.decryptEvent(this);
|
||||
if (options.isRetry === true) {
|
||||
logger.info(`Decrypted event on retry (${this.getDetails()})`);
|
||||
}
|
||||
this.setClearData(res);
|
||||
this._decryptionFailureReason = null;
|
||||
} catch (e) {
|
||||
const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
|
||||
|
||||
@@ -927,14 +955,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// so we don't bother to log `e` separately.
|
||||
logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
|
||||
|
||||
res = this.badEncryptedMessage(String(e));
|
||||
this.setClearDataForDecryptionFailure(String(e));
|
||||
this._decryptionFailureReason =
|
||||
e instanceof DecryptionError ? (<DecryptionError>e).code : DecryptionFailureCode.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
// at this point, we've either successfully decrypted the event, or have given up
|
||||
// (and set res to a 'badEncryptedMessage'). Either way, we can now set the
|
||||
// cleartext of the event and raise Event.decrypted.
|
||||
//
|
||||
// make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
|
||||
// Make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
|
||||
// otherwise the app will be confused to see `isBeingDecrypted` still set when
|
||||
// there isn't an `Event.decrypted` on the way.
|
||||
//
|
||||
@@ -942,7 +968,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
//
|
||||
this.decryptionPromise = null;
|
||||
this.retryDecryption = false;
|
||||
this.setClearData(res);
|
||||
|
||||
// Before we emit the event, clear the push actions so that they can be recalculated
|
||||
// by relevant code. We do this because the clear event has now changed, making it
|
||||
@@ -960,19 +985,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
}
|
||||
|
||||
private badEncryptedMessage(reason: string): IEventDecryptionResult {
|
||||
return {
|
||||
clearEvent: {
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: " + reason + " **",
|
||||
},
|
||||
},
|
||||
encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleartext data on this event.
|
||||
*
|
||||
@@ -981,9 +993,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @internal
|
||||
*
|
||||
* @param decryptionResult - the decryption result, including the plaintext and some key info
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link MatrixEventEvent.Decrypted}
|
||||
*/
|
||||
private setClearData(decryptionResult: IEventDecryptionResult): void {
|
||||
this.clearEvent = decryptionResult.clearEvent;
|
||||
@@ -991,7 +1000,28 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
|
||||
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
|
||||
this.untrusted = decryptionResult.untrusted || false;
|
||||
this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false;
|
||||
this.encryptedDisabledForUnverifiedDevices = false;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleartext data on this event after a decryption failure.
|
||||
*
|
||||
* @param reason - the textual reason for the failure
|
||||
*/
|
||||
private setClearDataForDecryptionFailure(reason: string): void {
|
||||
this.clearEvent = {
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: `** Unable to decrypt: ${reason} **`,
|
||||
},
|
||||
};
|
||||
this.senderCurve25519Key = null;
|
||||
this.claimedEd25519Key = null;
|
||||
this.forwardingCurve25519KeyChain = [];
|
||||
this.untrusted = false;
|
||||
this.encryptedDisabledForUnverifiedDevices = reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
@@ -1160,10 +1190,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
return this.visibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated In favor of the overload that includes a Room argument
|
||||
*/
|
||||
public makeRedacted(redactionEvent: MatrixEvent): void;
|
||||
/**
|
||||
* Update the content of an event in the same way it would be by the server
|
||||
* if it were redacted before it was sent to us
|
||||
@@ -1171,8 +1197,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @param redactionEvent - event causing the redaction
|
||||
* @param room - the room in which the event exists
|
||||
*/
|
||||
public makeRedacted(redactionEvent: MatrixEvent, room: Room): void;
|
||||
public makeRedacted(redactionEvent: MatrixEvent, room?: Room): void {
|
||||
public makeRedacted(redactionEvent: MatrixEvent, room: Room): void {
|
||||
// quick sanity-check
|
||||
if (!redactionEvent.event) {
|
||||
throw new Error("invalid redactionEvent in makeRedacted");
|
||||
@@ -1218,7 +1243,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
|
||||
// If the redacted event was in a thread (but not thread root), move it
|
||||
// to the main timeline. This will change if MSC3389 is merged.
|
||||
if (room && !this.isThreadRoot && this.threadRootId && this.threadRootId !== this.getId()) {
|
||||
if (!this.isThreadRoot && this.threadRootId && this.threadRootId !== this.getId()) {
|
||||
this.moveAllRelatedToMainTimeline(room);
|
||||
redactionEvent.moveToMainTimeline(room);
|
||||
}
|
||||
@@ -1355,19 +1380,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
return this.pushDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the push actions for this event.
|
||||
* Clears rule from push details if present
|
||||
* @deprecated use `setPushDetails`
|
||||
*
|
||||
* @param pushActions - push actions
|
||||
*/
|
||||
public setPushActions(pushActions: IActionsObject | null): void {
|
||||
this.pushDetails = {
|
||||
actions: pushActions || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the push details for this event.
|
||||
*
|
||||
@@ -1568,14 +1580,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this event is associated with another event. See `getAssociatedId`.
|
||||
* @deprecated use hasAssociation instead.
|
||||
*/
|
||||
public hasAssocation(): boolean {
|
||||
return !!this.getAssociatedId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this event is associated with another event. See `getAssociatedId`.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EventTimeline } from "./event-timeline";
|
||||
import { Preset } from "../@types/partials";
|
||||
import { globToRegexp } from "../utils";
|
||||
import { Room } from "./room";
|
||||
import { EventType, StateEvents } from "../@types/event";
|
||||
|
||||
/// The event type storing the user's individual policies.
|
||||
///
|
||||
@@ -37,7 +38,7 @@ export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue(
|
||||
);
|
||||
|
||||
/// The types of recommendations understood.
|
||||
enum PolicyRecommendation {
|
||||
export enum PolicyRecommendation {
|
||||
Ban = "m.ban",
|
||||
}
|
||||
|
||||
@@ -64,6 +65,12 @@ export enum PolicyScope {
|
||||
Server = "m.policy.server",
|
||||
}
|
||||
|
||||
const scopeToEventTypeMap: Record<PolicyScope, keyof StateEvents> = {
|
||||
[PolicyScope.User]: EventType.PolicyRuleUser,
|
||||
[PolicyScope.Room]: EventType.PolicyRuleRoom,
|
||||
[PolicyScope.Server]: EventType.PolicyRuleServer,
|
||||
};
|
||||
|
||||
/**
|
||||
* A container for ignored invites.
|
||||
*
|
||||
@@ -87,7 +94,7 @@ export class IgnoredInvites {
|
||||
*/
|
||||
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
|
||||
const target = await this.getOrCreateTargetRoom();
|
||||
const response = await this.client.sendStateEvent(target.roomId, scope, {
|
||||
const response = await this.client.sendStateEvent(target.roomId, scopeToEventTypeMap[scope], {
|
||||
entity,
|
||||
reason,
|
||||
recommendation: PolicyRecommendation.Ban,
|
||||
@@ -173,7 +180,7 @@ export class IgnoredInvites {
|
||||
{ scope: PolicyScope.User, entities: [sender] },
|
||||
{ scope: PolicyScope.Server, entities: [senderServer, roomServer] },
|
||||
]) {
|
||||
const events = state.getStateEvents(scope);
|
||||
const events = state.getStateEvents(scopeToEventTypeMap[scope]);
|
||||
for (const event of events) {
|
||||
const content = event.getContent();
|
||||
if (content?.recommendation != PolicyRecommendation.Ban) {
|
||||
|
||||
@@ -20,12 +20,13 @@ import { isNumber, removeHiddenChars } from "../utils";
|
||||
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
|
||||
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { MatrixClient } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule } from "../@types/partials";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import { RoomJoinRulesEventContent } from "../@types/state_events";
|
||||
|
||||
export interface IMarkerFoundOptions {
|
||||
/** Whether the timeline was empty before the marker event arrived in the
|
||||
@@ -53,6 +54,7 @@ enum OobStatus {
|
||||
export interface IPowerLevelsContent {
|
||||
users?: Record<string, number>;
|
||||
events?: Record<string, number>;
|
||||
notifications?: Partial<Record<"room", number>>;
|
||||
// eslint-disable-next-line camelcase
|
||||
users_default?: number;
|
||||
// eslint-disable-next-line camelcase
|
||||
@@ -60,6 +62,7 @@ export interface IPowerLevelsContent {
|
||||
// eslint-disable-next-line camelcase
|
||||
state_default?: number;
|
||||
ban?: number;
|
||||
invite?: number;
|
||||
kick?: number;
|
||||
redact?: number;
|
||||
}
|
||||
@@ -76,6 +79,8 @@ export enum RoomStateEvent {
|
||||
export type RoomStateEventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the event dictionary in room state is updated.
|
||||
* This does not guarantee that any related objects (like RoomMember) have been updated.
|
||||
* Use RoomStateEvent.Update for that.
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* @param state - The room state whose RoomState.events dictionary
|
||||
* was updated.
|
||||
@@ -130,6 +135,8 @@ export type RoomStateEventHandlerMap = {
|
||||
type EmittedEvents = RoomStateEvent | BeaconEvent;
|
||||
type EventHandlerMap = RoomStateEventHandlerMap & BeaconEventHandlerMap;
|
||||
|
||||
type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];
|
||||
|
||||
export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
|
||||
public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this);
|
||||
private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
|
||||
@@ -800,7 +807,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
* @param powerLevel - The power level of the member
|
||||
* @returns true if the given power level is sufficient
|
||||
*/
|
||||
public hasSufficientPowerLevelFor(action: "ban" | "kick" | "redact", powerLevel: number): boolean {
|
||||
public hasSufficientPowerLevelFor(
|
||||
action: KeysMatching<Required<IPowerLevelsContent>, number>,
|
||||
powerLevel: number,
|
||||
): boolean {
|
||||
const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
|
||||
|
||||
let powerLevels: IPowerLevelsContent = {};
|
||||
@@ -953,7 +963,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
*/
|
||||
public getJoinRule(): JoinRule {
|
||||
const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, "");
|
||||
const joinRuleContent: Partial<IJoinRuleEventContent> = joinRuleEvent?.getContent() ?? {};
|
||||
const joinRuleContent: Partial<RoomJoinRulesEventContent> = joinRuleEvent?.getContent() ?? {};
|
||||
return joinRuleContent["join_rule"] || JoinRule.Invite;
|
||||
}
|
||||
|
||||
|
||||
+128
-16
@@ -68,6 +68,7 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
|
||||
import { isPollEvent, Poll, PollEvent } from "./poll";
|
||||
import { RoomReceipts } from "./room-receipts";
|
||||
import { compareEventOrdering } from "./compare-event-ordering";
|
||||
import * as utils from "../utils";
|
||||
import { KnownMembership, Membership } from "../@types/membership";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
@@ -474,6 +475,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.name = roomId;
|
||||
this.normalizedName = roomId;
|
||||
|
||||
// Listen to our own receipt event as a more modular way of processing our own
|
||||
// receipts. No need to remove the listener: it's on ourself anyway.
|
||||
this.on(RoomEvent.Receipt, this.onReceipt);
|
||||
|
||||
// all our per-room timeline sets. the first one is the unfiltered ones;
|
||||
// the subsequent ones are the filtered ones in no particular order.
|
||||
this.timelineSets = [new EventTimelineSet(this, opts)];
|
||||
@@ -1306,6 +1311,98 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
private onReceipt(event: MatrixEvent): void {
|
||||
if (this.hasEncryptionStateEvent()) {
|
||||
this.clearNotificationsOnReceipt(event);
|
||||
}
|
||||
}
|
||||
|
||||
private clearNotificationsOnReceipt(event: MatrixEvent): void {
|
||||
// Like above, we have to listen for read receipts from ourselves in order to
|
||||
// correctly handle notification counts on encrypted rooms.
|
||||
// This fixes https://github.com/vector-im/element-web/issues/9421
|
||||
|
||||
// Figure out if we've read something or if it's just informational
|
||||
// We need to work out what threads we've just recieved receipts for, so we
|
||||
// know which ones to update. If we've received an unthreaded receipt, we'll
|
||||
// need to update all threads.
|
||||
let threadIds: string[] = [];
|
||||
let hasUnthreadedReceipt = false;
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
for (const receiptGroup of Object.values(content)) {
|
||||
for (const [receiptType, userReceipt] of Object.entries(receiptGroup)) {
|
||||
if (!utils.isSupportedReceiptType(receiptType)) continue;
|
||||
if (!userReceipt) continue;
|
||||
|
||||
for (const [userId, singleReceipt] of Object.entries(userReceipt)) {
|
||||
if (!singleReceipt || typeof singleReceipt !== "object") continue;
|
||||
const typedSingleReceipt = singleReceipt as Record<string, any>;
|
||||
if (userId !== this.client.getUserId()) continue;
|
||||
if (typedSingleReceipt.thread_id === undefined) {
|
||||
hasUnthreadedReceipt = true;
|
||||
} else if (typeof typedSingleReceipt.thread_id === "string") {
|
||||
threadIds.push(typedSingleReceipt.thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUnthreadedReceipt) {
|
||||
// If we have an unthreaded receipt, we need to update any threads that have a notification
|
||||
// in them (because we know the receipt can't go backwards so we don't need to check any with
|
||||
// no notifications: the number can only decrease from a receipt).
|
||||
threadIds = this.getThreads()
|
||||
.filter(
|
||||
(thread) =>
|
||||
this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0 ||
|
||||
this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) > 0,
|
||||
)
|
||||
.map((thread) => thread.id);
|
||||
threadIds.push("main");
|
||||
}
|
||||
|
||||
for (const threadId of threadIds) {
|
||||
// Work backwards to determine how many events are unread. We also set
|
||||
// a limit for how back we'll look to avoid spinning CPU for too long.
|
||||
// If we hit the limit, we assume the count is unchanged.
|
||||
const maxHistory = 20;
|
||||
const timeline = threadId === "main" ? this.getLiveTimeline() : this.getThread(threadId)?.liveTimeline;
|
||||
|
||||
if (!timeline) {
|
||||
logger.warn(`Couldn't find timeline for thread ID ${threadId} in room ${this.roomId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const events = timeline.getEvents();
|
||||
|
||||
let highlightCount = 0;
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (i === events.length - maxHistory) return; // limit reached
|
||||
|
||||
const event = events[i];
|
||||
|
||||
if (this.hasUserReadEvent(this.client.getUserId()!, event.getId()!)) {
|
||||
// If the user has read the event, then the counting is done.
|
||||
break;
|
||||
}
|
||||
|
||||
const pushActions = this.client.getPushActionsForEvent(event);
|
||||
highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
|
||||
}
|
||||
|
||||
// Note: we don't need to handle 'total' notifications because the counts
|
||||
// will come from the server.
|
||||
if (threadId === "main") {
|
||||
this.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
|
||||
} else {
|
||||
this.setThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight, highlightCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any devices in the room that are unverified
|
||||
*
|
||||
@@ -1512,18 +1609,31 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the thread notifications for this room
|
||||
* Resets the total thread notifications for all threads in this room to zero,
|
||||
* excluding any threads whose IDs are given in `exceptThreadIds`.
|
||||
*
|
||||
* If the room is not encrypted, also resets the highlight notification count to zero
|
||||
* for the same set of threads.
|
||||
*
|
||||
* This is intended for use from the sync code since we calculate highlight notification
|
||||
* counts locally from decrypted messages. We want to partially trust the total from the
|
||||
* server such that we clear notifications when read receipts arrive. The weird name is
|
||||
* intended to reflect this. You probably do not want to use this.
|
||||
*
|
||||
* @param exceptThreadIds - The thread IDs to exclude from the reset.
|
||||
*/
|
||||
public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void {
|
||||
if (notificationsToKeep) {
|
||||
for (const [threadId] of this.threadNotifications) {
|
||||
if (!notificationsToKeep.includes(threadId)) {
|
||||
this.threadNotifications.delete(threadId);
|
||||
public resetThreadUnreadNotificationCountFromSync(exceptThreadIds: string[] = []): void {
|
||||
const isEncrypted = this.hasEncryptionStateEvent();
|
||||
|
||||
for (const [threadId, notifs] of this.threadNotifications) {
|
||||
if (!exceptThreadIds.includes(threadId)) {
|
||||
notifs.total = 0;
|
||||
if (!isEncrypted) {
|
||||
notifs.highlight = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.threadNotifications.clear();
|
||||
}
|
||||
|
||||
this.emit(RoomEvent.UnreadNotifications);
|
||||
}
|
||||
|
||||
@@ -2345,6 +2455,15 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
receipts: this.cachedThreadReadReceipts.get(threadId) ?? [],
|
||||
});
|
||||
|
||||
// Add the re-emitter before we start adding events to the thread so we don't miss events
|
||||
this.reEmitter.reEmit(thread, [
|
||||
ThreadEvent.Delete,
|
||||
ThreadEvent.Update,
|
||||
ThreadEvent.NewReply,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
|
||||
// All read receipts should now come down from sync, we do not need to keep
|
||||
// a reference to the cached receipts anymore.
|
||||
this.cachedThreadReadReceipts.delete(threadId);
|
||||
@@ -2360,13 +2479,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// and pass the event through this.
|
||||
thread.addEvents(events, false);
|
||||
|
||||
this.reEmitter.reEmit(thread, [
|
||||
ThreadEvent.Delete,
|
||||
ThreadEvent.Update,
|
||||
ThreadEvent.NewReply,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
const isNewer =
|
||||
this.lastThread?.rootEvent &&
|
||||
rootEvent?.localTimestamp &&
|
||||
@@ -2966,7 +3078,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
[[], [], []],
|
||||
);
|
||||
} else {
|
||||
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
|
||||
// When `threadSupport` is disabled treat all events as timelineEvents
|
||||
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
|
||||
}
|
||||
}
|
||||
|
||||
+54
-39
@@ -47,11 +47,6 @@ export type ThreadEventHandlerMap = {
|
||||
[ThreadEvent.Delete]: (thread: Thread) => void;
|
||||
} & EventTimelineSetHandlerMap;
|
||||
|
||||
/**
|
||||
* @deprecated please use ThreadEventHandlerMap instead
|
||||
*/
|
||||
export type EventHandlerMap = ThreadEventHandlerMap;
|
||||
|
||||
interface IThreadOpts {
|
||||
room: Room;
|
||||
client: MatrixClient;
|
||||
@@ -105,7 +100,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
*
|
||||
* So it looks like this is only really relevant when initialEventsFetched
|
||||
* is false, because as soon as the initial events have been fetched, we
|
||||
* should have a timeline (I think).
|
||||
* should have a proper chunk of timeline from the pagination fetch.
|
||||
*
|
||||
* If all replies in this thread are redacted, this is set to the root
|
||||
* event. I'm not clear what the meaning of this is, since usually after the
|
||||
@@ -138,6 +133,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* that we've already fetched them.
|
||||
*/
|
||||
public initialEventsFetched = !Thread.hasServerSideSupport;
|
||||
private initalEventFetchProm: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* An array of events to add to the timeline once the thread has been initialised
|
||||
@@ -377,20 +373,20 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
// When there's no server-side support, just add it to the end of the timeline.
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
this.client.decryptEventIfNeeded(event);
|
||||
} else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
|
||||
// When we've asked for the event to be added to the end, and we're
|
||||
// not in the initial state, and this event belongs at the end, add it.
|
||||
this.addEventToTimeline(event, false);
|
||||
this.fetchEditsWhereNeeded(event);
|
||||
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
|
||||
this.addRelatedThreadEvent(event, toStartOfTimeline);
|
||||
return;
|
||||
} else if (this.initialEventsFetched) {
|
||||
// If initial events have not been fetched, we are OK to throw away
|
||||
// this event, because we are about to fetch all the events for this
|
||||
// thread from the server.
|
||||
|
||||
} else if (!toStartOfTimeline && isNewestReply) {
|
||||
// When we've asked for the event to be added to the end,
|
||||
// and this event belongs at the end, add it.
|
||||
this.addEventToTimeline(event, false);
|
||||
this.fetchEditsWhereNeeded(event);
|
||||
} else {
|
||||
// Otherwise, we should add it, but we suspect it is out of order.
|
||||
// This may be because we've just created the thread object and are
|
||||
// still fetching events, in which case add it where we think is sensible
|
||||
// and it will be removed and replaced with the events from the pagination
|
||||
// request once that completes.
|
||||
if (toStartOfTimeline) {
|
||||
// If we're adding at the start of the timeline, it doesn't
|
||||
// matter that it's out of order.
|
||||
@@ -606,29 +602,48 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
await this.processRootEventPromise;
|
||||
|
||||
if (!this.initialEventsFetched) {
|
||||
this.initialEventsFetched = true;
|
||||
// fetch initial event to allow proper pagination
|
||||
try {
|
||||
// if the thread has regular events, this will just load the last reply.
|
||||
// if the thread is newly created, this will load the root event.
|
||||
if (this.replyCount === 0 && this.rootEvent) {
|
||||
this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
|
||||
this.liveTimeline.setPaginationToken(null, Direction.Backward);
|
||||
} else {
|
||||
await this.client.paginateEventTimeline(this.liveTimeline, {
|
||||
backwards: true,
|
||||
});
|
||||
if (this.initalEventFetchProm) {
|
||||
await this.initalEventFetchProm;
|
||||
} else {
|
||||
// fetch initial events to allow proper pagination
|
||||
try {
|
||||
// clear out any events that were added before the pagination request
|
||||
// completed (eg. from sync). They'll be replaced by those from the pagination.
|
||||
// Really, we should do this after the pagination request completes, but
|
||||
// paginateEventTimeline does the request and adds the events in one go, so
|
||||
// this would need a refactor in order to do. It's therefore possible there's
|
||||
// a remaining race where an event comes in while the pagination request is
|
||||
// happening.
|
||||
this.timelineSet.resetLiveTimeline();
|
||||
// if the thread has regular events, this will just load the last reply.
|
||||
// if the thread is newly created, this will load the root event.
|
||||
if (this.replyCount === 0 && this.rootEvent) {
|
||||
this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
|
||||
this.liveTimeline.setPaginationToken(null, Direction.Backward);
|
||||
} else {
|
||||
this.initalEventFetchProm = this.client.paginateEventTimeline(this.liveTimeline, {
|
||||
backwards: true,
|
||||
});
|
||||
await this.initalEventFetchProm;
|
||||
}
|
||||
// We have now fetched the initial events, so set the flag. We need to do this before
|
||||
// we actually add the events, so `this.addEvents` knows that it can now safely add
|
||||
// them rather than buffer them in the pending event list. The main thing is that this
|
||||
// must remain false while the async fetch happens, so we don't try to add events before
|
||||
// the pagination has finished. The important thing is that we're not await-ing anything
|
||||
// else between setting this and adding events, so no races.
|
||||
this.initialEventsFetched = true;
|
||||
for (const event of this.replayEvents!) {
|
||||
this.addEvent(event, false);
|
||||
}
|
||||
this.replayEvents = null;
|
||||
// just to make sure that, if we've created a timeline window for this thread before the thread itself
|
||||
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
|
||||
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
|
||||
} catch (e) {
|
||||
logger.error("Failed to load start of newly created thread: ", e);
|
||||
this.initialEventsFetched = false;
|
||||
}
|
||||
for (const event of this.replayEvents!) {
|
||||
this.addEvent(event, false);
|
||||
}
|
||||
this.replayEvents = null;
|
||||
// just to make sure that, if we've created a timeline window for this thread before the thread itself
|
||||
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
|
||||
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
|
||||
} catch (e) {
|
||||
logger.error("Failed to load start of newly created thread: ", e);
|
||||
this.initialEventsFetched = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +704,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* Return last reply to the thread, if known.
|
||||
*/
|
||||
public lastReply(
|
||||
matches: (ev: MatrixEvent) => boolean = (ev): boolean => ev.isRelation(RelationType.Thread),
|
||||
matches: (ev: MatrixEvent) => boolean = (ev): boolean => ev.isRelation(THREAD_RELATION_TYPE.name),
|
||||
): MatrixEvent | null {
|
||||
for (let i = this.timeline.length - 1; i >= 0; i--) {
|
||||
const event = this.timeline[i];
|
||||
|
||||
+18
-27
@@ -50,23 +50,31 @@ interface OidcRegistrationRequestBody {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the client registration request
|
||||
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
|
||||
* @param clientMetadata - registration metadata
|
||||
* @returns resolves to the registered client id when registration is successful
|
||||
* @throws An `Error` with `message` set to an entry in {@link OidcError},
|
||||
* when the registration request fails, or the response is invalid.
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
* @throws when registration is not supported, on failed request or invalid response
|
||||
*/
|
||||
const doRegistration = async (
|
||||
registrationEndpoint: string,
|
||||
export const registerOidcClient = async (
|
||||
delegatedAuthConfig: OidcClientConfig,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"];
|
||||
if (grantTypes.some((scope) => !delegatedAuthConfig.metadata.grant_types_supported.includes(scope))) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
const metadata: OidcRegistrationRequestBody = {
|
||||
client_name: clientMetadata.clientName,
|
||||
client_uri: clientMetadata.clientUri,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
grant_types: grantTypes,
|
||||
redirect_uris: clientMetadata.redirectUris,
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
@@ -82,7 +90,7 @@ const doRegistration = async (
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(registrationEndpoint, {
|
||||
const response = await fetch(delegatedAuthConfig.registrationEndpoint, {
|
||||
method: Method.Post,
|
||||
headers,
|
||||
body: JSON.stringify(metadata),
|
||||
@@ -108,20 +116,3 @@ const doRegistration = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
* @throws when registration is not supported, on failed request or invalid response
|
||||
*/
|
||||
export const registerOidcClient = async (
|
||||
delegatedAuthConfig: OidcClientConfig,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
return doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
|
||||
};
|
||||
|
||||
+12
-32
@@ -15,17 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
|
||||
import { IdTokenClaims, OidcMetadata, SigninResponse } from "oidc-client-ts";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { OidcError } from "./error";
|
||||
|
||||
/**
|
||||
* re-export for backwards compatibility
|
||||
* @deprecated use OidcError
|
||||
*/
|
||||
export { OidcError as OidcDiscoveryError };
|
||||
|
||||
export type ValidatedIssuerConfig = {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
@@ -89,6 +83,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
||||
requiredStringProperty(wellKnown, "revocation_endpoint"),
|
||||
optionalStringProperty(wellKnown, "registration_endpoint"),
|
||||
optionalStringProperty(wellKnown, "account_management_uri"),
|
||||
optionalStringProperty(wellKnown, "device_authorization_endpoint"),
|
||||
optionalStringArrayProperty(wellKnown, "account_management_actions_supported"),
|
||||
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
||||
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
||||
@@ -124,6 +119,7 @@ export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
|
||||
| "response_types_supported"
|
||||
| "grant_types_supported"
|
||||
| "code_challenge_methods_supported"
|
||||
| "device_authorization_endpoint"
|
||||
> & {
|
||||
// MSC2965 extensions to the OIDC spec
|
||||
account_management_uri?: string;
|
||||
@@ -143,28 +139,7 @@ export function isValidatedIssuerMetadata(
|
||||
validateOIDCIssuerWellKnown(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JWT claims.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
|
||||
*/
|
||||
interface JwtClaims {
|
||||
[claim: string]: unknown;
|
||||
/** The "iss" (issuer) claim identifies the principal that issued the JWT. */
|
||||
iss?: string;
|
||||
/** The "sub" (subject) claim identifies the principal that is the subject of the JWT. */
|
||||
sub?: string;
|
||||
/** The "aud" (audience) claim identifies the recipients that the JWT is intended for. */
|
||||
aud?: string | string[];
|
||||
/** The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. */
|
||||
exp?: number;
|
||||
// unused claims excluded
|
||||
}
|
||||
interface IdTokenClaims extends JwtClaims {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
const decodeIdToken = (token: string): IdTokenClaims => {
|
||||
export const decodeIdToken = (token: string): IdTokenClaims => {
|
||||
try {
|
||||
return jwtDecode<IdTokenClaims>(token);
|
||||
} catch (error) {
|
||||
@@ -182,7 +157,12 @@ const decodeIdToken = (token: string): IdTokenClaims => {
|
||||
* @param nonce - nonce used in the authentication request
|
||||
* @throws when id token is invalid
|
||||
*/
|
||||
export const validateIdToken = (idToken: string | undefined, issuer: string, clientId: string, nonce: string): void => {
|
||||
export const validateIdToken = (
|
||||
idToken: string | undefined,
|
||||
issuer: string,
|
||||
clientId: string,
|
||||
nonce: string | undefined,
|
||||
): void => {
|
||||
try {
|
||||
if (!idToken) {
|
||||
throw new Error("No ID token");
|
||||
@@ -207,7 +187,7 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
|
||||
* If a nonce value was sent in the Authentication Request, a nonce Claim MUST be present and its value checked
|
||||
* to verify that it is the same value as the one that was sent in the Authentication Request.
|
||||
*/
|
||||
if (claims.nonce !== nonce) {
|
||||
if (nonce !== undefined && claims.nonce !== nonce) {
|
||||
throw new Error("Invalid nonce");
|
||||
}
|
||||
|
||||
@@ -275,7 +255,7 @@ export type BearerTokenResponse = {
|
||||
expires_in?: number;
|
||||
// from oidc-client-ts
|
||||
expires_at?: number;
|
||||
id_token?: string;
|
||||
id_token: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+32
-19
@@ -25,8 +25,8 @@ import {
|
||||
ICallStartedPrefixCondition,
|
||||
IContainsDisplayNameCondition,
|
||||
IEventMatchCondition,
|
||||
IEventPropertyIsCondition,
|
||||
IEventPropertyContainsCondition,
|
||||
IEventPropertyIsCondition,
|
||||
IPushRule,
|
||||
IPushRules,
|
||||
IRoomMemberCountCondition,
|
||||
@@ -115,8 +115,14 @@ const DEFAULT_OVERRIDE_RULES: Record<string, IPushRule> = {
|
||||
},
|
||||
};
|
||||
|
||||
const EXPECTED_DEFAULT_OVERRIDE_RULE_IDS = [
|
||||
// A special rule id for `EXPECTED_DEFAULT_OVERRIDE_RULE_IDS` and friends which denotes where user-defined rules live in the order.
|
||||
const UserDefinedRules = Symbol("UserDefinedRules");
|
||||
|
||||
type OrderedRules = Array<string | typeof UserDefinedRules>;
|
||||
|
||||
const EXPECTED_DEFAULT_OVERRIDE_RULE_IDS: OrderedRules = [
|
||||
RuleId.Master,
|
||||
UserDefinedRules,
|
||||
RuleId.SuppressNotices,
|
||||
RuleId.InviteToSelf,
|
||||
RuleId.MemberEvent,
|
||||
@@ -151,8 +157,10 @@ const DEFAULT_UNDERRIDE_RULES: Record<string, IPushRule> = {
|
||||
},
|
||||
};
|
||||
|
||||
const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS = [
|
||||
const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
|
||||
UserDefinedRules,
|
||||
RuleId.IncomingCall,
|
||||
".org.matrix.msc3914.rule.room.call",
|
||||
RuleId.EncryptedDM,
|
||||
RuleId.DM,
|
||||
RuleId.Message,
|
||||
@@ -162,35 +170,40 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS = [
|
||||
/**
|
||||
* Make sure that each of the rules listed in `defaultRuleIds` is listed in the given set of push rules.
|
||||
*
|
||||
* @param kind - the kind of push rule set being merged.
|
||||
* @param incomingRules - the existing set of known push rules for the user.
|
||||
* @param defaultRules - a lookup table for the default definitions of push rules.
|
||||
* @param defaultRuleIds - the IDs of the expected default push rules, in order.
|
||||
* @param orderedRuleIds - the IDs of the expected push rules, in order.
|
||||
*
|
||||
* @returns A copy of `incomingRules`, with any missing default rules inserted in the right place.
|
||||
*/
|
||||
function mergeRulesWithDefaults(
|
||||
kind: PushRuleKind,
|
||||
incomingRules: IPushRule[],
|
||||
defaultRules: Record<string, IPushRule>,
|
||||
defaultRuleIds: string[],
|
||||
orderedRuleIds: OrderedRules,
|
||||
): IPushRule[] {
|
||||
// Calculate the index after the last default rule in `incomingRules`
|
||||
// to allow us to split the incomingRules into defaults and custom
|
||||
let firstCustomRuleIndex = incomingRules.findIndex((r) => !r.default);
|
||||
if (firstCustomRuleIndex < 0) firstCustomRuleIndex = incomingRules.length;
|
||||
// Split the incomingRules into defaults and custom
|
||||
const incomingDefaultRules = incomingRules.filter((rule) => rule.default);
|
||||
const incomingCustomRules = incomingRules.filter((rule) => !rule.default);
|
||||
|
||||
function insertDefaultPushRule(ruleId: string): void {
|
||||
if (ruleId in defaultRules) {
|
||||
logger.warn(`Adding default global push rule ${ruleId}`);
|
||||
function insertDefaultPushRule(ruleId: OrderedRules[number]): void {
|
||||
if (ruleId === UserDefinedRules) {
|
||||
// Re-insert any user-defined rules that were in `incomingRules`
|
||||
newRules.push(...incomingCustomRules);
|
||||
} else if (ruleId in defaultRules) {
|
||||
logger.warn(`Adding default global ${kind} push rule ${ruleId}`);
|
||||
newRules.push(defaultRules[ruleId]);
|
||||
} else {
|
||||
logger.warn(`Missing default global push rule ${ruleId}`);
|
||||
logger.warn(`Missing default global ${kind} push rule ${ruleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
let nextExpectedRuleIdIndex = 0;
|
||||
const newRules: IPushRule[] = [];
|
||||
for (const rule of incomingRules.slice(0, firstCustomRuleIndex)) {
|
||||
const ruleIndex = defaultRuleIds.indexOf(rule.rule_id);
|
||||
// Merge our expected rules (including the incoming custom rules) into the incoming default rules.
|
||||
for (const rule of incomingDefaultRules) {
|
||||
const ruleIndex = orderedRuleIds.indexOf(rule.rule_id);
|
||||
if (ruleIndex === -1) {
|
||||
// an unrecognised rule; copy it over
|
||||
newRules.push(rule);
|
||||
@@ -198,7 +211,7 @@ function mergeRulesWithDefaults(
|
||||
}
|
||||
while (ruleIndex > nextExpectedRuleIdIndex) {
|
||||
// insert new rules
|
||||
const defaultRuleId = defaultRuleIds[nextExpectedRuleIdIndex];
|
||||
const defaultRuleId = orderedRuleIds[nextExpectedRuleIdIndex];
|
||||
insertDefaultPushRule(defaultRuleId);
|
||||
nextExpectedRuleIdIndex += 1;
|
||||
}
|
||||
@@ -208,12 +221,10 @@ function mergeRulesWithDefaults(
|
||||
}
|
||||
|
||||
// Now copy over any remaining default rules
|
||||
for (const ruleId of defaultRuleIds.slice(nextExpectedRuleIdIndex)) {
|
||||
for (const ruleId of orderedRuleIds.slice(nextExpectedRuleIdIndex)) {
|
||||
insertDefaultPushRule(ruleId);
|
||||
}
|
||||
|
||||
// Finally any non-default rules that were in `incomingRules`
|
||||
newRules.push(...incomingRules.slice(firstCustomRuleIndex));
|
||||
return newRules;
|
||||
}
|
||||
|
||||
@@ -281,12 +292,14 @@ export class PushProcessor {
|
||||
|
||||
// Merge the client-level defaults with the ones from the server
|
||||
newRules.global.override = mergeRulesWithDefaults(
|
||||
PushRuleKind.Override,
|
||||
newRules.global.override,
|
||||
DEFAULT_OVERRIDE_RULES,
|
||||
EXPECTED_DEFAULT_OVERRIDE_RULE_IDS,
|
||||
);
|
||||
|
||||
newRules.global.underride = mergeRulesWithDefaults(
|
||||
PushRuleKind.Underride,
|
||||
newRules.global.underride,
|
||||
DEFAULT_UNDERRIDE_RULES,
|
||||
EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { encodeUri } from "../utils";
|
||||
import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { crypto } from "../crypto/crypto";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
|
||||
import { Logger } from "../logger";
|
||||
|
||||
/**
|
||||
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
|
||||
*/
|
||||
interface DehydratedDeviceResp {
|
||||
device_id: string;
|
||||
device_data: {
|
||||
algorithm: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* The response body of `POST /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/events`.
|
||||
*/
|
||||
interface DehydratedDeviceEventsResp {
|
||||
events: IToDeviceEvent[];
|
||||
next_batch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unstable URL prefix for dehydrated device endpoints
|
||||
*/
|
||||
export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1";
|
||||
/**
|
||||
* The name used for the dehydration key in Secret Storage
|
||||
*/
|
||||
const SECRET_STORAGE_NAME = "org.matrix.msc3814";
|
||||
|
||||
/**
|
||||
* The interval between creating dehydrated devices. (one week)
|
||||
*/
|
||||
const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Manages dehydrated devices
|
||||
*
|
||||
* We have one of these per `RustCrypto`. It's responsible for
|
||||
*
|
||||
* * determining server support for dehydrated devices
|
||||
* * creating new dehydrated devices when requested, including periodically
|
||||
* replacing the dehydrated device with a new one
|
||||
* * rehydrating a device when requested, and when present
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class DehydratedDeviceManager {
|
||||
/** the secret key used for dehydrating and rehydrating */
|
||||
private key?: Uint8Array;
|
||||
/** the ID of the interval for periodically replacing the dehydrated device */
|
||||
private intervalId?: ReturnType<typeof setInterval>;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return whether the server supports dehydrated devices.
|
||||
*/
|
||||
public async isSupported(): Promise<boolean> {
|
||||
// call the endpoint to get a dehydrated device. If it returns an
|
||||
// M_UNRECOGNIZED error, then dehydration is unsupported. If it returns
|
||||
// a successful response, or an M_NOT_FOUND, then dehydration is supported.
|
||||
// Any other exceptions are passed through.
|
||||
try {
|
||||
await this.http.authedRequest<DehydratedDeviceResp>(
|
||||
Method.Get,
|
||||
"/dehydrated_device",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as MatrixError;
|
||||
if (err.errcode === "M_UNRECOGNIZED") {
|
||||
return false;
|
||||
} else if (err.errcode === "M_NOT_FOUND") {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param createNewKey - whether to force creation of a new dehydration key.
|
||||
* This can be used, for example, if Secret Storage is being reset.
|
||||
*/
|
||||
public async start(createNewKey?: boolean): Promise<void> {
|
||||
this.stop();
|
||||
try {
|
||||
await this.rehydrateDeviceIfAvailable();
|
||||
} catch (e) {
|
||||
// If rehydration fails, there isn't much we can do about it. Log
|
||||
// the error, and create a new device.
|
||||
this.logger.info("dehydration: Error rehydrating device:", e);
|
||||
}
|
||||
if (createNewKey) {
|
||||
await this.resetKey();
|
||||
}
|
||||
await this.scheduleDeviceDehydration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether the dehydration key is stored in Secret Storage.
|
||||
*/
|
||||
public async isKeyStored(): Promise<boolean> {
|
||||
return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the dehydration key.
|
||||
*
|
||||
* Creates a new key and stores it in secret storage.
|
||||
*/
|
||||
public async resetKey(): Promise<void> {
|
||||
const key = new Uint8Array(32);
|
||||
crypto.getRandomValues(key);
|
||||
await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key));
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and cache the encryption key from secret storage.
|
||||
*
|
||||
* If `create` is `true`, creates a new key if no existing key is present.
|
||||
*
|
||||
* @returns the key, if available, or `null` if no key is available
|
||||
*/
|
||||
private async getKey(create: boolean): Promise<Uint8Array | null> {
|
||||
if (this.key === undefined) {
|
||||
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
|
||||
if (keyB64 === undefined) {
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
await this.resetKey();
|
||||
} else {
|
||||
this.key = decodeBase64(keyB64);
|
||||
}
|
||||
}
|
||||
return this.key!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate the dehydrated device stored on the server.
|
||||
*
|
||||
* Checks if there is a dehydrated device on the server. If so, rehydrates
|
||||
* the device and processes the to-device events.
|
||||
*
|
||||
* Returns whether or not a dehydrated device was found.
|
||||
*/
|
||||
public async rehydrateDeviceIfAvailable(): Promise<boolean> {
|
||||
const key = await this.getKey(false);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dehydratedDeviceResp;
|
||||
try {
|
||||
dehydratedDeviceResp = await this.http.authedRequest<DehydratedDeviceResp>(
|
||||
Method.Get,
|
||||
"/dehydrated_device",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as MatrixError;
|
||||
// We ignore M_NOT_FOUND (there is no dehydrated device, so nothing
|
||||
// us to do) and M_UNRECOGNIZED (the server does not understand the
|
||||
// endpoint). We pass through any other errors.
|
||||
if (err.errcode === "M_NOT_FOUND" || err.errcode === "M_UNRECOGNIZED") {
|
||||
this.logger.info("dehydration: No dehydrated device");
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.logger.info("dehydration: dehydrated device found");
|
||||
|
||||
const rehydratedDevice = await this.olmMachine
|
||||
.dehydratedDevices()
|
||||
.rehydrate(
|
||||
key,
|
||||
new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id),
|
||||
JSON.stringify(dehydratedDeviceResp.device_data),
|
||||
);
|
||||
|
||||
this.logger.info("dehydration: device rehydrated");
|
||||
|
||||
let nextBatch: string | undefined = undefined;
|
||||
let toDeviceCount = 0;
|
||||
let roomKeyCount = 0;
|
||||
const path = encodeUri("/dehydrated_device/$device_id/events", {
|
||||
$device_id: dehydratedDeviceResp.device_id,
|
||||
});
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const eventResp: DehydratedDeviceEventsResp = await this.http.authedRequest<DehydratedDeviceEventsResp>(
|
||||
Method.Post,
|
||||
path,
|
||||
undefined,
|
||||
nextBatch ? { next_batch: nextBatch } : {},
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
|
||||
if (eventResp.events.length === 0) {
|
||||
break;
|
||||
}
|
||||
toDeviceCount += eventResp.events.length;
|
||||
nextBatch = eventResp.next_batch;
|
||||
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
|
||||
roomKeyCount += roomKeyInfos.length;
|
||||
}
|
||||
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and uploads a new dehydrated device.
|
||||
*
|
||||
* Creates and stores a new key in secret storage if none is available.
|
||||
*/
|
||||
public async createAndUploadDehydratedDevice(): Promise<void> {
|
||||
const key = (await this.getKey(true))!;
|
||||
|
||||
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
|
||||
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
|
||||
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
|
||||
this.logger.info("dehydration: uploaded device");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule periodic creation of dehydrated devices.
|
||||
*/
|
||||
public async scheduleDeviceDehydration(): Promise<void> {
|
||||
// cancel any previously-scheduled tasks
|
||||
this.stop();
|
||||
|
||||
await this.createAndUploadDehydratedDevice();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.createAndUploadDehydratedDevice().catch((error) => {
|
||||
this.logger.error("Error creating dehydrated device:", error);
|
||||
});
|
||||
}, DEHYDRATION_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the dehydrated device manager.
|
||||
*
|
||||
* Cancels any scheduled dehydration tasks.
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
OlmMachine,
|
||||
PutDehydratedDeviceRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
import { logger } from "../logger";
|
||||
import { calculateRetryBackoff, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { logDuration, QueryDict, sleep } from "../utils";
|
||||
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { AuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceManager";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
@@ -62,7 +64,7 @@ export class OutgoingRequestProcessor {
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest<T>(
|
||||
msg: OutgoingRequest | UploadSigningKeysRequest,
|
||||
msg: OutgoingRequest | UploadSigningKeysRequest | PutDehydratedDeviceRequest,
|
||||
uiaCallback?: UIAuthCallback<T>,
|
||||
): Promise<void> {
|
||||
let resp: string;
|
||||
@@ -102,6 +104,11 @@ export class OutgoingRequestProcessor {
|
||||
);
|
||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else if (msg instanceof PutDehydratedDeviceRequest) {
|
||||
const path = DehydrationUnstablePrefix + "/dehydrated_device";
|
||||
await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
@@ -169,7 +176,7 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
|
||||
const parsedBody = JSON.parse(body);
|
||||
const makeRequest = async (auth: IAuthDict | null): Promise<UIAResponse<T>> => {
|
||||
const makeRequest = async (auth: AuthDict | null): Promise<UIAResponse<T>> => {
|
||||
const newBody: Record<string, any> = {
|
||||
...parsedBody,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { Curve25519AuthData, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
import { Curve25519AuthData, KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
import { Logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RustBackupManager } from "./backup";
|
||||
@@ -76,7 +76,11 @@ type Configuration = {
|
||||
export class PerSessionKeyBackupDownloader {
|
||||
private stopped = false;
|
||||
|
||||
/** The version and decryption key to use with current backup if all set up correctly */
|
||||
/**
|
||||
* The version and decryption key to use with current backup if all set up correctly.
|
||||
*
|
||||
* Will not be set unless `hasConfigurationProblem` is `false`.
|
||||
*/
|
||||
private configuration: Configuration | null = null;
|
||||
|
||||
/** We remember when a session was requested and not found in backup to avoid query again too soon.
|
||||
@@ -119,6 +123,24 @@ export class PerSessionKeyBackupDownloader {
|
||||
backupManager.on(CryptoEvent.KeyBackupDecryptionKeyCached, this.onBackupStatusChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key download is successfully configured and active.
|
||||
*
|
||||
* @return `true` if key download is correctly configured and active; otherwise `false`.
|
||||
*/
|
||||
public isKeyBackupDownloadConfigured(): boolean {
|
||||
return this.configuration !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the details of the latest backup on the server, when we last checked.
|
||||
*
|
||||
* This is just a convenience method to expose {@link RustBackupManager.getServerBackupInfo}.
|
||||
*/
|
||||
public async getServerBackupInfo(): Promise<KeyBackupInfo | null | undefined> {
|
||||
return await this.backupManager.getServerBackupInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a MissingRoomKey or UnknownMessageIndex decryption error is encountered.
|
||||
*
|
||||
@@ -410,7 +432,7 @@ export class PerSessionKeyBackupDownloader {
|
||||
private async internalCheckFromServer(): Promise<Configuration | null> {
|
||||
let currentServerVersion = null;
|
||||
try {
|
||||
currentServerVersion = await this.backupManager.requestKeyBackupVersion();
|
||||
currentServerVersion = await this.backupManager.getServerBackupInfo();
|
||||
} catch (e) {
|
||||
this.logger.debug(`Backup: error while checking server version: ${e}`);
|
||||
this.hasConfigurationProblem = true;
|
||||
|
||||
@@ -58,6 +58,16 @@ interface KeyBackupCreationInfo {
|
||||
export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap> {
|
||||
/** Have we checked if there is a backup on the server which we can use */
|
||||
private checkedForBackup = false;
|
||||
|
||||
/**
|
||||
* The latest backup version on the server, when we last checked.
|
||||
*
|
||||
* If there was no backup on the server, `null`. If our attempt to check resulted in an error, `undefined`.
|
||||
*
|
||||
* Note that the backup was not necessarily verified.
|
||||
*/
|
||||
private serverBackupInfo: KeyBackupInfo | null | undefined = undefined;
|
||||
|
||||
private activeBackupVersion: string | null = null;
|
||||
private stopped = false;
|
||||
|
||||
@@ -89,6 +99,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
return this.activeBackupVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the details of the latest backup on the server, when we last checked.
|
||||
*
|
||||
* This normally returns a cached value, but if we haven't yet made a request to the server, it will fire one off.
|
||||
* It will always return the details of the active backup if key backup is enabled.
|
||||
*
|
||||
* If there was no backup on the server, `null`. If our attempt to check resulted in an error, `undefined`.
|
||||
*/
|
||||
public async getServerBackupInfo(): Promise<KeyBackupInfo | null | undefined> {
|
||||
// Do a validity check if we haven't already done one. The check is likely to fail if we don't yet have the
|
||||
// backup keys -- but as a side-effect, it will populate `serverBackupInfo`.
|
||||
await this.checkKeyBackupAndEnable(false);
|
||||
return this.serverBackupInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a key backup can be trusted.
|
||||
*
|
||||
@@ -242,18 +267,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
/** Helper for `checkKeyBackup` */
|
||||
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
|
||||
logger.log("Checking key backup status...");
|
||||
let backupInfo: KeyBackupInfo | null = null;
|
||||
let backupInfo: KeyBackupInfo | null | undefined;
|
||||
try {
|
||||
backupInfo = await this.requestKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.warn("Error checking for active key backup", e);
|
||||
this.serverBackupInfo = undefined;
|
||||
return null;
|
||||
}
|
||||
this.checkedForBackup = true;
|
||||
|
||||
if (backupInfo && !backupInfo.version) {
|
||||
logger.warn("active backup lacks a useful 'version'; ignoring it");
|
||||
backupInfo = undefined;
|
||||
}
|
||||
this.serverBackupInfo = backupInfo;
|
||||
|
||||
const activeVersion = await this.getActiveBackupVersion();
|
||||
|
||||
@@ -462,12 +490,13 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current key backup from the server
|
||||
*
|
||||
* @returns Information object from API or null if there is no active backup.
|
||||
*/
|
||||
public async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
|
||||
private async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
|
||||
return await requestKeyBackupVersion(this.http);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: Rus
|
||||
verified,
|
||||
signatures,
|
||||
displayName: device.displayName,
|
||||
dehydrated: device.isDehydrated,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ import { decryptAES, IEncryptedPayload } from "../crypto/aes";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { requestKeyBackupVersion } from "./backup";
|
||||
import { IRoomEncryption } from "../crypto/RoomList";
|
||||
import { CrossSigningKeyInfo } from "../crypto-api";
|
||||
import { CrossSigningKeyInfo, Curve25519AuthData } from "../crypto-api";
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { KeyBackupInfo } from "../crypto-api/keybackup";
|
||||
import { sleep } from "../utils";
|
||||
import { encodeBase64 } from "../base64";
|
||||
|
||||
/**
|
||||
* Determine if any data needs migrating from the legacy store, and do so.
|
||||
@@ -105,7 +108,7 @@ export async function migrateFromLegacyCrypto(args: {
|
||||
|
||||
if (migrationState === MigrationState.NOT_STARTED) {
|
||||
logger.info("Migrating data from legacy crypto store. Step 1: base data");
|
||||
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle);
|
||||
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle, logger);
|
||||
|
||||
migrationState = MigrationState.INITIAL_DATA_MIGRATED;
|
||||
await legacyStore.setMigrationState(migrationState);
|
||||
@@ -144,6 +147,7 @@ async function migrateBaseData(
|
||||
legacyStore: CryptoStore,
|
||||
pickleKey: Uint8Array,
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const migrationData = new RustSdkCryptoJs.BaseMigrationData();
|
||||
migrationData.userId = new RustSdkCryptoJs.UserId(userId);
|
||||
@@ -158,12 +162,41 @@ async function migrateBaseData(
|
||||
const recoveryKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "m.megolm_backup.v1");
|
||||
|
||||
// If we have a backup recovery key, we need to try to figure out which backup version it is for.
|
||||
// All we can really do is ask the server for the most recent version.
|
||||
// All we can really do is ask the server for the most recent version and check if the cached key we have matches.
|
||||
// It is possible that the backup has changed since last time his session was opened.
|
||||
if (recoveryKey) {
|
||||
const backupInfo = await requestKeyBackupVersion(http);
|
||||
if (backupInfo) {
|
||||
migrationData.backupVersion = backupInfo.version;
|
||||
migrationData.backupRecoveryKey = recoveryKey;
|
||||
let backupCallDone = false;
|
||||
let backupInfo: KeyBackupInfo | null = null;
|
||||
while (!backupCallDone) {
|
||||
try {
|
||||
backupInfo = await requestKeyBackupVersion(http);
|
||||
backupCallDone = true;
|
||||
} catch (e) {
|
||||
logger.info("Failed to get backup version during migration, retrying in 2 seconds", e);
|
||||
// Retry until successful, use simple constant delay
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
if (backupInfo && backupInfo.algorithm == "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
// check if the recovery key matches, as the active backup version may have changed since the key was cached
|
||||
// and the migration started.
|
||||
try {
|
||||
const decryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(recoveryKey);
|
||||
const publicKey = (backupInfo.auth_data as Curve25519AuthData)?.public_key;
|
||||
const isValid = decryptionKey.megolmV1PublicKey.publicKeyBase64 == publicKey;
|
||||
if (isValid) {
|
||||
migrationData.backupVersion = backupInfo.version;
|
||||
migrationData.backupRecoveryKey = recoveryKey;
|
||||
} else {
|
||||
logger.debug(
|
||||
"The backup key to migrate does not match the active backup version",
|
||||
`Cached pub key: ${decryptionKey.megolmV1PublicKey.publicKeyBase64}`,
|
||||
`Active pub key: ${publicKey}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("Failed to check if the backup key to migrate matches the active backup version", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,19 +401,20 @@ async function getAndDecryptCachedSecretKey(
|
||||
legacyPickleKey: Uint8Array,
|
||||
name: string,
|
||||
): Promise<string | undefined> {
|
||||
let encodedKey: IEncryptedPayload | null = null;
|
||||
|
||||
await legacyStore.doTxn("readonly", "account", (txn) => {
|
||||
legacyStore.getSecretStorePrivateKey(
|
||||
txn,
|
||||
(k) => {
|
||||
encodedKey = k as IEncryptedPayload | null;
|
||||
},
|
||||
name as keyof SecretStorePrivateKeys,
|
||||
);
|
||||
const key = await new Promise<any>((resolve) => {
|
||||
legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
legacyStore.getSecretStorePrivateKey(txn, resolve, name as keyof SecretStorePrivateKeys);
|
||||
});
|
||||
});
|
||||
|
||||
return encodedKey === null ? undefined : await decryptAES(encodedKey, legacyPickleKey, name);
|
||||
if (key && key.ciphertext && key.iv && key.mac) {
|
||||
return await decryptAES(key as IEncryptedPayload, legacyPickleKey, name);
|
||||
} else if (key instanceof Uint8Array) {
|
||||
// This is a legacy backward compatibility case where the key was stored in clear.
|
||||
return encodeBase64(key);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+128
-55
@@ -18,12 +18,13 @@ import anotherjson from "another-json";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import type { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
CrossSigningStatus,
|
||||
CryptoCallbacks,
|
||||
Curve25519AuthData,
|
||||
DecryptionFailureCode,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
@@ -70,11 +72,17 @@ import { randomString } from "../randomstring";
|
||||
import { ClientStoppedError } from "../errors";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { encodeBase64 } from "../base64";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
||||
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
|
||||
import { DehydratedDeviceManager } from "./DehydratedDeviceManager";
|
||||
import { VerificationMethod } from "../types";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
|
||||
const ALL_VERIFICATION_METHODS = [
|
||||
VerificationMethod.Sas,
|
||||
VerificationMethod.ScanQrCode,
|
||||
VerificationMethod.ShowQrCode,
|
||||
VerificationMethod.Reciprocate,
|
||||
];
|
||||
|
||||
interface ISignableObject {
|
||||
signatures?: ISignatures;
|
||||
@@ -101,9 +109,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
private crossSigningIdentity: CrossSigningIdentity;
|
||||
private readonly backupManager: RustBackupManager;
|
||||
private outgoingRequestsManager: OutgoingRequestsManager;
|
||||
|
||||
private readonly perSessionBackupDownloader: PerSessionKeyBackupDownloader;
|
||||
|
||||
private readonly dehydratedDeviceManager: DehydratedDeviceManager;
|
||||
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
||||
|
||||
public constructor(
|
||||
@@ -142,14 +149,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
|
||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||
|
||||
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
|
||||
this.logger,
|
||||
this.olmMachine,
|
||||
this.http,
|
||||
this.backupManager,
|
||||
);
|
||||
|
||||
this.dehydratedDeviceManager = new DehydratedDeviceManager(
|
||||
this.logger,
|
||||
olmMachine,
|
||||
http,
|
||||
this.outgoingRequestProcessor,
|
||||
secretStorage,
|
||||
);
|
||||
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
|
||||
|
||||
this.reemitter.reEmit(this.backupManager, [
|
||||
@@ -206,6 +218,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
this.backupManager.stop();
|
||||
this.outgoingRequestsManager.stop();
|
||||
this.perSessionBackupDownloader.stop();
|
||||
this.dehydratedDeviceManager.stop();
|
||||
|
||||
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
|
||||
// cleaned up; in particular, the indexeddb connections will be closed, which means they
|
||||
@@ -598,15 +611,17 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Implementation of {@link CryptoApi#isCrossSigningReady}
|
||||
*/
|
||||
public async isCrossSigningReady(): Promise<boolean> {
|
||||
const { publicKeysOnDevice, privateKeysInSecretStorage, privateKeysCachedLocally } =
|
||||
await this.getCrossSigningStatus();
|
||||
const { privateKeysInSecretStorage, privateKeysCachedLocally } = await this.getCrossSigningStatus();
|
||||
const hasKeysInCache =
|
||||
Boolean(privateKeysCachedLocally.masterKey) &&
|
||||
Boolean(privateKeysCachedLocally.selfSigningKey) &&
|
||||
Boolean(privateKeysCachedLocally.userSigningKey);
|
||||
|
||||
// The cross signing is ready if the public and private keys are available
|
||||
return publicKeysOnDevice && (hasKeysInCache || privateKeysInSecretStorage);
|
||||
const identity = await this.getOwnIdentity();
|
||||
|
||||
// Cross-signing is ready if the public identity is trusted, and the private keys
|
||||
// are either cached, or accessible via secret-storage.
|
||||
return !!identity?.isVerified() && (hasKeysInCache || privateKeysInSecretStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1206,6 +1221,23 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return await this.dehydratedDeviceManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#startDehydration}.
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
|
||||
throw new Error("Device dehydration requires cross-signing and secret storage to be set up");
|
||||
}
|
||||
return await this.dehydratedDeviceManager.start(createNewKey);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -1677,52 +1709,93 @@ class EventDecryptor {
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
} catch (err) {
|
||||
// We need to map back to regular decryption errors (used for analytics for example)
|
||||
// The DecryptionErrors are used by react-sdk so is implicitly part of API, but poorly typed
|
||||
if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) {
|
||||
const content = event.getWireContent();
|
||||
let jsError;
|
||||
switch (err.code) {
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey: {
|
||||
jsError = new DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.perSessionBackupDownloader.onDecryptionKeyMissingError(
|
||||
event.getRoomId()!,
|
||||
event.getWireContent().session_id!,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
|
||||
jsError = new DecryptionError(
|
||||
"OLM_UNKNOWN_MESSAGE_INDEX",
|
||||
"The sender's device has not sent us the keys for this message at this index.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.perSessionBackupDownloader.onDecryptionKeyMissingError(
|
||||
event.getRoomId()!,
|
||||
event.getWireContent().session_id!,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
|
||||
// Just put it on the `UNABLE_TO_DECRYPT` bucket.
|
||||
default: {
|
||||
jsError = new DecryptionError("UNABLE_TO_DECRYPT", err.description, {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw jsError;
|
||||
this.onMegolmDecryptionError(event, err, await this.perSessionBackupDownloader.getServerBackupInfo());
|
||||
} else {
|
||||
throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "Unknown error");
|
||||
}
|
||||
throw new DecryptionError("UNABLE_TO_DECRYPT", "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a `MegolmDecryptionError` returned by the rust SDK.
|
||||
*
|
||||
* Fires off a request to the `perSessionBackupDownloader`, if appropriate, and then throws a `DecryptionError`.
|
||||
*
|
||||
* @param event - The event which could not be decrypted.
|
||||
* @param err - The error from the Rust SDK.
|
||||
* @param serverBackupInfo - Details about the current backup from the server. `null` if there is no backup.
|
||||
* `undefined` if our attempt to check failed.
|
||||
*/
|
||||
private onMegolmDecryptionError(
|
||||
event: MatrixEvent,
|
||||
err: RustSdkCryptoJs.MegolmDecryptionError,
|
||||
serverBackupInfo: KeyBackupInfo | null | undefined,
|
||||
): never {
|
||||
const content = event.getWireContent();
|
||||
const errorDetails = { session: content.sender_key + "|" + content.session_id };
|
||||
|
||||
// If the error looks like it might be recoverable from backup, queue up a request to try that.
|
||||
if (
|
||||
err.code === RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey ||
|
||||
err.code === RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex
|
||||
) {
|
||||
this.perSessionBackupDownloader.onDecryptionKeyMissingError(event.getRoomId()!, content.session_id!);
|
||||
|
||||
// If the server is telling us our membership at the time the event
|
||||
// was sent, and it isn't "join", we use a different error code.
|
||||
const membership = event.getMembershipAtEvent();
|
||||
if (membership && membership !== KnownMembership.Join && membership !== KnownMembership.Invite) {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
"This message was sent when we were not a member of the room.",
|
||||
errorDetails,
|
||||
);
|
||||
}
|
||||
|
||||
// If the event was sent before this device was created, we use some different error codes.
|
||||
if (event.getTs() <= this.olmMachine.deviceCreationTimeMs) {
|
||||
if (serverBackupInfo === null) {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
|
||||
"This message was sent before this device logged in, and there is no key backup on the server.",
|
||||
errorDetails,
|
||||
);
|
||||
} else if (!this.perSessionBackupDownloader.isKeyBackupDownloadConfigured()) {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
"This message was sent before this device logged in, and key backup is not working.",
|
||||
errorDetails,
|
||||
);
|
||||
} else {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP,
|
||||
"This message was sent before this device logged in. Key backup is working, but we still do not (yet) have the key.",
|
||||
errorDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (err.code) {
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey:
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
errorDetails,
|
||||
);
|
||||
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex:
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
"The sender's device has not sent us the keys for this message at this index.",
|
||||
errorDetails,
|
||||
);
|
||||
|
||||
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
|
||||
// Just put it on the `UNKNOWN_ERROR` bucket.
|
||||
default:
|
||||
throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, err.description, errorDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { TypedReEmitter } from "../ReEmitter";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { EventType, MsgType } from "../@types/event";
|
||||
import { defer, IDeferred } from "../utils";
|
||||
import { VerificationMethod } from "../types";
|
||||
|
||||
/**
|
||||
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
|
||||
@@ -230,9 +231,9 @@ export class RustVerificationRequest
|
||||
|
||||
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
|
||||
if (verification instanceof RustSdkCryptoJs.Sas) {
|
||||
return "m.sas.v1";
|
||||
return VerificationMethod.Sas;
|
||||
} else if (verification instanceof RustSdkCryptoJs.Qr) {
|
||||
return "m.reciprocate.v1";
|
||||
return VerificationMethod.Reciprocate;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -336,7 +337,7 @@ export class RustVerificationRequest
|
||||
* @param method - the name of the verification method to use.
|
||||
*/
|
||||
public async startVerification(method: string): Promise<Verifier> {
|
||||
if (method !== "m.sas.v1") {
|
||||
if (method !== VerificationMethod.Sas) {
|
||||
throw new Error(`Unsupported verification method ${method}`);
|
||||
}
|
||||
|
||||
@@ -566,8 +567,10 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
|
||||
// application to prompt the user to confirm their side.
|
||||
if (this.callbacks === null && this.inner.hasBeenScanned()) {
|
||||
this.callbacks = {
|
||||
confirm: () => this.confirmScanning(),
|
||||
cancel: () => this.cancel(),
|
||||
confirm: (): void => {
|
||||
this.confirmScanning();
|
||||
},
|
||||
cancel: (): void => this.cancel(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -756,10 +759,10 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
|
||||
|
||||
/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */
|
||||
const verificationMethodsByIdentifier: Record<string, RustSdkCryptoJs.VerificationMethod> = {
|
||||
"m.sas.v1": RustSdkCryptoJs.VerificationMethod.SasV1,
|
||||
"m.qr_code.scan.v1": RustSdkCryptoJs.VerificationMethod.QrCodeScanV1,
|
||||
"m.qr_code.show.v1": RustSdkCryptoJs.VerificationMethod.QrCodeShowV1,
|
||||
"m.reciprocate.v1": RustSdkCryptoJs.VerificationMethod.ReciprocateV1,
|
||||
[VerificationMethod.Sas]: RustSdkCryptoJs.VerificationMethod.SasV1,
|
||||
[VerificationMethod.ScanQrCode]: RustSdkCryptoJs.VerificationMethod.QrCodeScanV1,
|
||||
[VerificationMethod.ShowQrCode]: RustSdkCryptoJs.VerificationMethod.QrCodeShowV1,
|
||||
[VerificationMethod.Reciprocate]: RustSdkCryptoJs.VerificationMethod.ReciprocateV1,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+17
-46
@@ -32,7 +32,6 @@ import { deepCopy, defer, IDeferred, noUnsafeEventProps, promiseMapSeries, unsaf
|
||||
import { Filter } from "./filter";
|
||||
import { EventTimeline } from "./models/event-timeline";
|
||||
import { logger } from "./logger";
|
||||
import { InvalidStoreError, InvalidStoreState } from "./errors";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
|
||||
import {
|
||||
IEphemeral,
|
||||
@@ -593,25 +592,6 @@ export class SyncApi {
|
||||
await keepaliveProm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the lazy loading option different than in previous session?
|
||||
* @param lazyLoadMembers - current options for lazy loading
|
||||
* @returns whether or not the option has changed compared to the previous session */
|
||||
private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise<boolean> {
|
||||
// assume it was turned off before
|
||||
// if we don't know any better
|
||||
let lazyLoadMembersBefore = false;
|
||||
const isStoreNewlyCreated = await this.client.store.isNewlyCreated();
|
||||
if (!isStoreNewlyCreated) {
|
||||
const prevClientOptions = await this.client.store.getClientOptions();
|
||||
if (prevClientOptions) {
|
||||
lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers;
|
||||
}
|
||||
return lazyLoadMembersBefore !== lazyLoadMembers;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private shouldAbortSync(error: MatrixError): boolean {
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// The logout already happened, we just need to stop.
|
||||
@@ -649,9 +629,9 @@ export class SyncApi {
|
||||
return filter;
|
||||
};
|
||||
|
||||
private checkLazyLoadStatus = async (): Promise<void> => {
|
||||
debuglog("Checking lazy load status...");
|
||||
if (this.opts.lazyLoadMembers && this.client.isGuest()) {
|
||||
private prepareLazyLoadingForSync = async (): Promise<void> => {
|
||||
debuglog("Prepare lazy loading for sync...");
|
||||
if (this.client.isGuest()) {
|
||||
this.opts.lazyLoadMembers = false;
|
||||
}
|
||||
if (this.opts.lazyLoadMembers) {
|
||||
@@ -661,23 +641,12 @@ export class SyncApi {
|
||||
}
|
||||
this.opts.filter.setLazyLoadMembers(true);
|
||||
}
|
||||
// need to vape the store when enabling LL and wasn't enabled before
|
||||
debuglog("Checking whether lazy loading has changed in store...");
|
||||
const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers);
|
||||
if (shouldClear) {
|
||||
this.storeIsInvalid = true;
|
||||
const error = new InvalidStoreError(InvalidStoreState.ToggledLazyLoading, !!this.opts.lazyLoadMembers);
|
||||
this.updateSyncState(SyncState.Error, { error });
|
||||
// bail out of the sync loop now: the app needs to respond to this error.
|
||||
// we leave the state as 'ERROR' which isn't great since this normally means
|
||||
// we're retrying. The client must be stopped before clearing the stores anyway
|
||||
// so the app should stop the client, clear the store and start it again.
|
||||
logger.warn("InvalidStoreError: store is not usable: stopping sync.");
|
||||
return;
|
||||
}
|
||||
if (this.opts.lazyLoadMembers) {
|
||||
this.syncOpts.crypto?.enableLazyLoading();
|
||||
}
|
||||
};
|
||||
|
||||
private storeClientOptions = async (): Promise<void> => {
|
||||
try {
|
||||
debuglog("Storing client options...");
|
||||
await this.client.storeClientOptions();
|
||||
@@ -757,14 +726,16 @@ export class SyncApi {
|
||||
// 1) We need to get push rules so we can check if events should bing as we get
|
||||
// them from /sync.
|
||||
// 2) We need to get/create a filter which we can use for /sync.
|
||||
// 3) We need to check the lazy loading option matches what was used in the
|
||||
// stored sync. If it doesn't, we can't use the stored sync.
|
||||
// 3) We need to prepare lazy loading for sync
|
||||
// 4) We need to store the client options
|
||||
|
||||
// Now start the first incremental sync request: this can also
|
||||
// take a while so if we set it going now, we can wait for it
|
||||
// to finish while we process our saved sync data.
|
||||
await this.getPushRules();
|
||||
await this.checkLazyLoadStatus();
|
||||
await this.prepareLazyLoadingForSync();
|
||||
await this.storeClientOptions();
|
||||
|
||||
const { filterId, filter } = await this.getFilter();
|
||||
if (!filter) return; // bail, getFilter failed
|
||||
|
||||
@@ -1355,11 +1326,11 @@ export class SyncApi {
|
||||
const unreadThreadNotifications =
|
||||
joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!];
|
||||
if (unreadThreadNotifications) {
|
||||
// Only partially reset unread notification
|
||||
// We want to keep the client-generated count. Particularly important
|
||||
// for encrypted room that refresh their notification count on event
|
||||
// decryption
|
||||
room.resetThreadUnreadNotificationCount(Object.keys(unreadThreadNotifications));
|
||||
// This mirrors the logic above for rooms: take the *total* notification count from
|
||||
// the server for unencrypted rooms or is it's zero. Any threads not present in this
|
||||
// object implicitly have zero notifications, so start by clearing the total counts
|
||||
// for all such threads.
|
||||
room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications));
|
||||
for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
|
||||
if (!encrypted || unreadNotification.notification_count === 0) {
|
||||
room.setThreadUnreadNotificationCount(
|
||||
@@ -1380,7 +1351,7 @@ export class SyncApi {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
room.resetThreadUnreadNotificationCount();
|
||||
room.resetThreadUnreadNotificationCountFromSync();
|
||||
}
|
||||
|
||||
joinObj.timeline = joinObj.timeline || ({} as ITimeline);
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file is a secondary entrypoint for the js-sdk library, exposing utilities which might be useful for writing tests.
|
||||
*
|
||||
* In general, it should not be included in runtime applications.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { IContent, IEvent, IUnsigned, MatrixEvent } from "./models/event";
|
||||
import { RoomMember } from "./models/room-member";
|
||||
import { EventType } from "./@types/event";
|
||||
import { DecryptionError } from "./crypto/algorithms";
|
||||
import { DecryptionFailureCode } from "./crypto-api";
|
||||
import { EventDecryptionResult } from "./common-crypto/CryptoBackend";
|
||||
|
||||
/**
|
||||
* Create a {@link MatrixEvent}.
|
||||
*
|
||||
* @param opts - Values for the event.
|
||||
*/
|
||||
export function mkMatrixEvent(opts: {
|
||||
/** Room ID of the event. */
|
||||
roomId: string;
|
||||
|
||||
/** The sender of the event. */
|
||||
sender: string;
|
||||
|
||||
/** The type of the event. */
|
||||
type: EventType | string;
|
||||
|
||||
/** Optional `state_key` for the event. If unspecified, a non-state event is created. */
|
||||
stateKey?: string;
|
||||
|
||||
/** Optional `origin_server_ts` for the event. If unspecified, the timestamp will be set to 0. */
|
||||
ts?: number;
|
||||
|
||||
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
|
||||
eventId?: string;
|
||||
|
||||
/** Content of the event. */
|
||||
content: IContent;
|
||||
|
||||
/** Optional `unsigned` data for the event. */
|
||||
unsigned?: IUnsigned;
|
||||
}): MatrixEvent {
|
||||
const event: Partial<IEvent> = {
|
||||
type: opts.type,
|
||||
room_id: opts.roomId,
|
||||
sender: opts.sender,
|
||||
content: opts.content,
|
||||
event_id: opts.eventId ?? "$" + Math.random() + "-" + Math.random(),
|
||||
origin_server_ts: opts.ts ?? 0,
|
||||
unsigned: opts.unsigned,
|
||||
};
|
||||
if (opts.stateKey !== undefined) {
|
||||
event.state_key = opts.stateKey;
|
||||
}
|
||||
|
||||
const mxEvent = new MatrixEvent(event);
|
||||
mxEvent.sender = {
|
||||
userId: opts.sender,
|
||||
membership: "join",
|
||||
name: opts.sender,
|
||||
rawDisplayName: opts.sender,
|
||||
roomId: opts.sender,
|
||||
getAvatarUrl: () => {},
|
||||
getMxcAvatarUrl: () => {},
|
||||
} as unknown as RoomMember;
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `MatrixEvent` representing a successfully-decrypted `m.room.encrypted` event.
|
||||
*
|
||||
* @param opts - Values for the event.
|
||||
*/
|
||||
export async function mkEncryptedMatrixEvent(opts: {
|
||||
/** Room ID of the event. */
|
||||
roomId: string;
|
||||
|
||||
/** The sender of the event. */
|
||||
sender: string;
|
||||
|
||||
/** The type the event will have, once it has been decrypted. */
|
||||
plainType: EventType | string;
|
||||
|
||||
/** The content the event will have, once it has been decrypted. */
|
||||
plainContent: IContent;
|
||||
|
||||
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
|
||||
eventId?: string;
|
||||
}): Promise<MatrixEvent> {
|
||||
const mxEvent = mkMatrixEvent({
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
roomId: opts.roomId,
|
||||
sender: opts.sender,
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
eventId: opts.eventId,
|
||||
});
|
||||
|
||||
await decryptExistingEvent(mxEvent, { plainType: opts.plainType, plainContent: opts.plainContent });
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `MatrixEvent` representing a `m.room.encrypted` event which could not be decrypted.
|
||||
*
|
||||
* @param opts - Values for the event.
|
||||
*/
|
||||
export async function mkDecryptionFailureMatrixEvent(opts: {
|
||||
/** Room ID of the event. */
|
||||
roomId: string;
|
||||
|
||||
/** The sender of the event. */
|
||||
sender: string;
|
||||
|
||||
/** The reason code for the failure */
|
||||
code: DecryptionFailureCode;
|
||||
|
||||
/** A textual reason for the failure */
|
||||
msg: string;
|
||||
|
||||
/** Optional `event_id` for the event. If provided will be used as event ID; else an ID is generated. */
|
||||
eventId?: string;
|
||||
}): Promise<MatrixEvent> {
|
||||
const mxEvent = mkMatrixEvent({
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
roomId: opts.roomId,
|
||||
sender: opts.sender,
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
eventId: opts.eventId,
|
||||
});
|
||||
|
||||
const mockCrypto = {
|
||||
decryptEvent: async (_ev): Promise<EventDecryptionResult> => {
|
||||
throw new DecryptionError(opts.code, opts.msg);
|
||||
},
|
||||
} as Parameters<MatrixEvent["attemptDecryption"]>[0];
|
||||
await mxEvent.attemptDecryption(mockCrypto);
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event previously returned by {@link mkDecryptionFailureMatrixEvent}, simulate a successful re-decryption
|
||||
* attempt.
|
||||
*
|
||||
* @param mxEvent - The event that will be decrypted.
|
||||
* @param opts - New data for the successful decryption.
|
||||
*/
|
||||
export async function decryptExistingEvent(
|
||||
mxEvent: MatrixEvent,
|
||||
opts: {
|
||||
/** The type the event will have, once it has been decrypted. */
|
||||
plainType: EventType | string;
|
||||
|
||||
/** The content the event will have, once it has been decrypted. */
|
||||
plainContent: IContent;
|
||||
},
|
||||
): Promise<void> {
|
||||
const decryptionResult: EventDecryptionResult = {
|
||||
claimedEd25519Key: "",
|
||||
clearEvent: {
|
||||
type: opts.plainType,
|
||||
content: opts.plainContent,
|
||||
},
|
||||
forwardingCurve25519KeyChain: [],
|
||||
senderCurve25519Key: "",
|
||||
untrusted: false,
|
||||
};
|
||||
|
||||
const mockCrypto = {
|
||||
decryptEvent: async (_ev): Promise<EventDecryptionResult> => decryptionResult,
|
||||
} as Parameters<MatrixEvent["attemptDecryption"]>[0];
|
||||
await mxEvent.attemptDecryption(mockCrypto);
|
||||
}
|
||||
@@ -23,3 +23,36 @@ limitations under the License.
|
||||
|
||||
export type * from "./@types/media";
|
||||
export * from "./@types/membership";
|
||||
export type * from "./@types/event";
|
||||
export type * from "./@types/events";
|
||||
export type * from "./@types/state_events";
|
||||
|
||||
/** The different methods for device and user verification */
|
||||
export enum VerificationMethod {
|
||||
/** Short authentication string (emoji or decimals).
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#short-authentication-string-sas-verification
|
||||
*/
|
||||
Sas = "m.sas.v1",
|
||||
|
||||
/**
|
||||
* Verification by showing a QR code which is scanned by the other device.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
|
||||
*/
|
||||
ShowQrCode = "m.qr_code.show.v1",
|
||||
|
||||
/**
|
||||
* Verification by scanning a QR code that is shown by the other device.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
|
||||
*/
|
||||
ScanQrCode = "m.qr_code.scan.v1",
|
||||
|
||||
/**
|
||||
* Verification by confirming that we have scanned a QR code.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.9/client-server-api/#qr-codes
|
||||
*/
|
||||
Reciprocate = "m.reciprocate.v1",
|
||||
}
|
||||
|
||||
+29
-11
@@ -26,8 +26,8 @@ import { parse as parseSdp, write as writeSdp } from "sdp-transform";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { checkObjectHasKeys, isNullOrUndefined, recursivelyAssign } from "../utils";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { EventType, ToDeviceMessageId } from "../@types/event";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { EventType, TimelineEvents, ToDeviceMessageId } from "../@types/event";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { randomString } from "../randomstring";
|
||||
import {
|
||||
@@ -293,13 +293,24 @@ function getCodecParamMods(isPtt: boolean): CodecParamsMod[] {
|
||||
return mods;
|
||||
}
|
||||
|
||||
type CallEventType =
|
||||
| EventType.CallReplaces
|
||||
| EventType.CallAnswer
|
||||
| EventType.CallSelectAnswer
|
||||
| EventType.CallNegotiate
|
||||
| EventType.CallInvite
|
||||
| EventType.CallCandidates
|
||||
| EventType.CallHangup
|
||||
| EventType.CallReject
|
||||
| EventType.CallSDPStreamMetadataChangedPrefix;
|
||||
|
||||
export interface VoipEvent {
|
||||
type: "toDevice" | "sendEvent";
|
||||
eventType: string;
|
||||
userId?: string;
|
||||
opponentDeviceId?: string;
|
||||
roomId?: string;
|
||||
content: Record<string, unknown>;
|
||||
content: TimelineEvents[CallEventType];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,7 +417,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
// If candidates arrive before we've picked an opponent (which, in particular,
|
||||
// will happen if the opponent sends candidates eagerly before the user answers
|
||||
// the call) we buffer them up here so we can then add the ones from the party we pick
|
||||
private remoteCandidateBuffer = new Map<string, RTCIceCandidate[]>();
|
||||
private remoteCandidateBuffer = new Map<string, MCallCandidates["candidates"]>();
|
||||
|
||||
private remoteAssertedIdentity?: AssertedIdentity;
|
||||
private remoteSDPStreamMetadata?: SDPStreamMetadata;
|
||||
@@ -1156,7 +1167,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
this.terminate(CallParty.Local, reason, !suppressEvent);
|
||||
// We don't want to send hangup here if we didn't even get to sending an invite
|
||||
if ([CallState.Fledgling, CallState.WaitLocalMedia].includes(this.state)) return;
|
||||
const content: IContent = {};
|
||||
const content: Omit<MCallHangupReject, "version" | "call_id" | "party_id" | "conf_id"> = {};
|
||||
// Don't send UserHangup reason to older clients
|
||||
if ((this.opponentVersion && this.opponentVersion !== 0) || reason !== CallErrorCode.UserHangup) {
|
||||
content["reason"] = reason;
|
||||
@@ -1916,7 +1927,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
if (this.opponentPartyId !== null) {
|
||||
try {
|
||||
await this.sendVoipEvent(EventType.CallSelectAnswer, {
|
||||
selected_party_id: this.opponentPartyId,
|
||||
selected_party_id: this.opponentPartyId!,
|
||||
});
|
||||
} catch (err) {
|
||||
// This isn't fatal, and will just mean that if another party has raced to answer
|
||||
@@ -2012,6 +2023,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
logger.debug(`Call ${this.callId} onNegotiateReceived() create an answer`);
|
||||
|
||||
this.sendVoipEvent(EventType.CallNegotiate, {
|
||||
lifetime: CALL_TIMEOUT_MS,
|
||||
description: this.peerConn!.localDescription?.toJSON(),
|
||||
[SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
|
||||
});
|
||||
@@ -2444,13 +2456,17 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
private async sendVoipEvent(eventType: string, content: object): Promise<void> {
|
||||
const realContent = Object.assign({}, content, {
|
||||
private async sendVoipEvent<K extends keyof Pick<TimelineEvents, CallEventType>>(
|
||||
eventType: K,
|
||||
content: Omit<TimelineEvents[K], "version" | "call_id" | "party_id" | "conf_id">,
|
||||
): Promise<void> {
|
||||
const realContent = {
|
||||
...content,
|
||||
version: VOIP_PROTO_VERSION,
|
||||
call_id: this.callId,
|
||||
party_id: this.ourPartyId,
|
||||
conf_id: this.groupCallId,
|
||||
});
|
||||
} as TimelineEvents[K];
|
||||
|
||||
if (this.opponentDeviceId) {
|
||||
const toDeviceSeq = this.toDeviceSeq++;
|
||||
@@ -2729,7 +2745,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
const candidates = this.candidateSendQueue;
|
||||
this.candidateSendQueue = [];
|
||||
++this.candidateSendTries;
|
||||
const content = { candidates: candidates.map((candidate) => candidate.toJSON()) };
|
||||
const content: Pick<MCallCandidates, "candidates"> = {
|
||||
candidates: candidates.map((candidate) => candidate.toJSON()),
|
||||
};
|
||||
if (this.candidatesEnded) {
|
||||
// If there are no more candidates, signal this by adding an empty string candidate
|
||||
content.candidates.push({
|
||||
@@ -2923,7 +2941,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
this.remoteCandidateBuffer.clear();
|
||||
}
|
||||
|
||||
private async addIceCandidates(candidates: RTCIceCandidate[]): Promise<void> {
|
||||
private async addIceCandidates(candidates: RTCIceCandidate[] | MCallCandidates["candidates"]): Promise<void> {
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
(candidate.sdpMid === null || candidate.sdpMid === undefined) &&
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CallReplacesTarget {
|
||||
|
||||
export interface MCallBase {
|
||||
call_id: string;
|
||||
conf_id?: string;
|
||||
version: string | number;
|
||||
party_id?: string;
|
||||
sender_session_id?: string;
|
||||
@@ -82,7 +83,7 @@ export interface MCAllAssertedIdentity extends MCallBase {
|
||||
}
|
||||
|
||||
export interface MCallCandidates extends MCallBase {
|
||||
candidates: RTCIceCandidate[];
|
||||
candidates: Omit<RTCIceCandidateInit, "usernameFragment">[];
|
||||
}
|
||||
|
||||
export interface MCallHangupReject extends MCallBase {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { SummaryStatsReportGatherer } from "./stats/summaryStatsReportGatherer";
|
||||
import { CallFeedStatsReporter } from "./stats/callFeedStatsReporter";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import { CallMembershipData } from "../matrixrtc/CallMembership";
|
||||
|
||||
export enum GroupCallIntent {
|
||||
Ring = "m.ring",
|
||||
@@ -167,6 +168,7 @@ export interface IGroupCallDataChannelOptions {
|
||||
export interface IGroupCallRoomState {
|
||||
"m.intent": GroupCallIntent;
|
||||
"m.type": GroupCallType;
|
||||
"m.terminated"?: GroupCallTerminationReason;
|
||||
"io.element.ptt"?: boolean;
|
||||
// TODO: Specify data-channels
|
||||
"dataChannelsEnabled"?: boolean;
|
||||
@@ -196,6 +198,11 @@ export interface IGroupCallRoomMemberState {
|
||||
"m.calls": IGroupCallRoomMemberCallState[];
|
||||
}
|
||||
|
||||
// XXX: this hasn't made it into the MSC yet
|
||||
export interface ExperimentalGroupCallRoomMemberState {
|
||||
memberships: CallMembershipData[];
|
||||
}
|
||||
|
||||
export enum GroupCallState {
|
||||
LocalCallFeedUninitialized = "local_call_feed_uninitialized",
|
||||
InitializingLocalCallFeed = "initializing_local_call_feed",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://typedoc.org/schema.json",
|
||||
"plugin": ["typedoc-plugin-mdn-links", "typedoc-plugin-missing-exports", "typedoc-plugin-coverage"],
|
||||
"coverageLabel": "TypeDoc",
|
||||
"entryPoints": ["src/matrix.ts", "src/types.ts"],
|
||||
"entryPoints": ["src/matrix.ts", "src/types.ts", "src/testing.ts"],
|
||||
"excludeExternals": true,
|
||||
"out": "_docs"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user