Compare commits
144 Commits
v24.1.0
...
v25.2.0-rc.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ec1fa6605 | |||
| f15d682938 | |||
| 21a10a2d14 | |||
| fc02e550bd | |||
| 78637a0689 | |||
| 4ca882fcd4 | |||
| 13ee0eb7f5 | |||
| cb018dfc80 | |||
| 7574dacdb3 | |||
| 0c417b7c32 | |||
| daf845d7bd | |||
| 52792ec89b | |||
| 6dc4a62e8c | |||
| 1cd8ea61ea | |||
| 0c5eb277e4 | |||
| d459a91af3 | |||
| 18722d0031 | |||
| 5119934268 | |||
| 077da23d08 | |||
| 083b4cb17e | |||
| 4316009401 | |||
| 72f3c360b6 | |||
| fcbc195fbe | |||
| af38021d28 | |||
| 5e8cb9fa18 | |||
| 6ef9f6c55e | |||
| e6a3b0ebc0 | |||
| aaae55736f | |||
| 9e586ab634 | |||
| 7ff44d4a50 | |||
| 63abd00ca7 | |||
| 40f2579158 | |||
| ceb2a57feb | |||
| 90e8336797 | |||
| 73ca9c9ed2 | |||
| cc065c2772 | |||
| 028be0fee2 | |||
| d4500da59a | |||
| b6aef6772e | |||
| 49696cecbd | |||
| 83cb52c89c | |||
| e82bae2c4d | |||
| ee2b0204aa | |||
| 1ec7670f6a | |||
| 8ab2e10471 | |||
| 8ff8685ae5 | |||
| f3772cdf82 | |||
| ee2f1cdfd4 | |||
| 0de73a0b3e | |||
| be742e811c | |||
| ca9263fa64 | |||
| 9f619be08d | |||
| 7792254c12 | |||
| fff41f1f27 | |||
| 3e0f9f582e | |||
| 47ba8cfa24 | |||
| 54bb4c8011 | |||
| 71e763263e | |||
| 2ebcda2a55 | |||
| e10db6f7e9 | |||
| 1e041a2957 | |||
| 1631d6f3c0 | |||
| 3d86821258 | |||
| 261bc81554 | |||
| 8f701f43fb | |||
| 4bdb9111dd | |||
| 93e2135223 | |||
| 56cb05aac0 | |||
| 38d5b202c9 | |||
| cfffa9c518 | |||
| 73dbd709d8 | |||
| eef67e2c03 | |||
| e3498f0668 | |||
| f04c147faa | |||
| 33bbd45f1e | |||
| fd91a534c7 | |||
| c2afc357b9 | |||
| 1d3f67f2ce | |||
| 514e4f07f1 | |||
| fbb1c4b2bd | |||
| 63dea599b1 | |||
| 743ba5f050 | |||
| cb180b4195 | |||
| c805b7e29d | |||
| 8f6814450b | |||
| 37e8391cde | |||
| 90234402a7 | |||
| 8ecf603d73 | |||
| af243581ff | |||
| 01afed9ff9 | |||
| 2687bb37fb | |||
| 65b1a10803 | |||
| 9d230ef0d6 | |||
| a03438f2af | |||
| c622e9260f | |||
| 8bf53d6f90 | |||
| 8c30a3b0df | |||
| c61d53eed0 | |||
| 95f7d1d347 | |||
| 72d70bb929 | |||
| 4f67e59692 | |||
| d40d5c8a39 | |||
| 87398ac555 | |||
| de3d5ead42 | |||
| 1e1b571b28 | |||
| f400a7b1b2 | |||
| a0bcb5777f | |||
| f8a625eddb | |||
| 69a2a15b95 | |||
| b9d0596dd7 | |||
| 72af8c193c | |||
| ed8c326856 | |||
| f5bf6b1be6 | |||
| 0e19f8dc69 | |||
| 6049c0bf37 | |||
| a102253f30 | |||
| f71d86f005 | |||
| 70e34ffb76 | |||
| 91aa7b26e6 | |||
| 5fb97fcce4 | |||
| 3d1a450129 | |||
| a58c5aacdf | |||
| d7e165a279 | |||
| a57ee803f1 | |||
| 170a52b09f | |||
| 2be5889d18 | |||
| ca6b574bee | |||
| 57b0172a2d | |||
| 53260ee25d | |||
| 964281322f | |||
| 7c3f483396 | |||
| 59784aa9fe | |||
| 72a2b6d571 | |||
| 5854af0eae | |||
| acd3d3a804 | |||
| 1b8c04a430 | |||
| 378b73f8b8 | |||
| c482a6ab15 | |||
| 2daa429b77 | |||
| 6ebbc15359 | |||
| 9a840d484c | |||
| e89467c9fb | |||
| 0b396c005c | |||
| d05313f95e |
@@ -14,7 +14,7 @@ jobs:
|
||||
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
|
||||
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
|
||||
jobs:
|
||||
changelog:
|
||||
name: Preview Changelog
|
||||
|
||||
@@ -11,6 +11,12 @@ jobs:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: _docs
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -19,38 +25,25 @@ jobs:
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: 📖 Generate JSDoc
|
||||
run: "yarn gendoc"
|
||||
|
||||
- name: 📋 Copy to temp
|
||||
- name: 🔨 Install symlinks
|
||||
run: |
|
||||
cp -a "./_docs" "$RUNNER_TEMP/"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y symlinks
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
|
||||
- name: 🔪 Prepare
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
- name: 📖 Generate docs
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
[ ! -e "$VERSION" ] || rm -r $VERSION
|
||||
cp -r $RUNNER_TEMP/_docs/ $VERSION
|
||||
|
||||
# Add the new directory to the index if it isn't there already
|
||||
if ! grep -q ">Version $VERSION</a>" index.html; then
|
||||
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
|
||||
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
|
||||
fi
|
||||
yarn tpv purge --yes --out _docs --stale --major 10
|
||||
yarn gendoc
|
||||
symlinks -rc _docs
|
||||
|
||||
- name: 🚀 Deploy
|
||||
uses: peaceiris/actions-gh-pages@bd8c6b06eba6b3d25d72b7a1767993c0aeee42e7 # v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
keep_files: true
|
||||
publish_dir: .
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
git add . --all
|
||||
git commit -m "Update docs"
|
||||
git push
|
||||
working-directory: _docs
|
||||
|
||||
npm:
|
||||
name: Publish
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.3
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.5
|
||||
# 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:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
|
||||
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -65,7 +65,14 @@ jobs:
|
||||
run: "yarn install"
|
||||
|
||||
- name: Generate Docs
|
||||
run: "yarn run gendoc"
|
||||
run: "yarn run gendoc --treatWarningsAsErrors"
|
||||
|
||||
# Upload artifact duplicates symlink contents so we do this to save 75% space
|
||||
- name: Flatten symlink and write _redirects
|
||||
run: |
|
||||
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
|
||||
find _docs -mindepth 1 -maxdepth 1 -type l -delete
|
||||
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
+19
-27
@@ -8,6 +8,8 @@ on:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
|
||||
jobs:
|
||||
jest:
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
|
||||
@@ -38,28 +40,22 @@ jobs:
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
|
||||
|
||||
- name: Load metrics reporter
|
||||
id: metrics
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
run: |
|
||||
echo "extra-reporter='--reporters=<rootDir>/spec/slowReporter.js'" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
yarn ${{ github.event_name == 'merge_group' && 'test' || 'coverage' }} \
|
||||
yarn test \
|
||||
--coverage=${{ env.ENABLE_COVERAGE }} \
|
||||
--ci \
|
||||
--reporters github-actions ${{ steps.metrics.outputs.extra-reporter }} \
|
||||
--max-workers ${{ steps.cpu-cores.outputs.count }} \
|
||||
./spec/${{ matrix.specs }}
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: github.event_name != 'merge_group'
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: github.event_name != 'merge_group'
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage
|
||||
@@ -67,22 +63,6 @@ jobs:
|
||||
coverage
|
||||
!coverage/lcov-report
|
||||
|
||||
skip_sonar:
|
||||
name: Skip SonarCloud on merge_queue
|
||||
if: github.event_name == 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
needs: jest
|
||||
steps:
|
||||
- name: Skip SonarCloud
|
||||
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
description: SonarCloud skipped
|
||||
context: SonarCloud Code Analysis
|
||||
sha: ${{ github.sha }}
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
matrix-react-sdk:
|
||||
name: Downstream test matrix-react-sdk
|
||||
if: github.event_name == 'merge_group'
|
||||
@@ -91,7 +71,8 @@ jobs:
|
||||
disable_coverage: true
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
|
||||
# Hook for branch protection to work outside merge queues
|
||||
# Hook for branch protection to skip downstream testing outside of merge queues
|
||||
# and skip sonarcloud coverage within merge queues
|
||||
downstream:
|
||||
name: Downstream tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -99,5 +80,16 @@ jobs:
|
||||
needs:
|
||||
- matrix-react-sdk
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
description: SonarCloud skipped
|
||||
context: SonarCloud Code Analysis
|
||||
sha: ${{ github.sha }}
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- if: needs.matrix-react-sdk.result != 'skipped' && needs.matrix-react-sdk.result != 'success'
|
||||
run: exit 1
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@2b011faafdcbc9ceb11414d64d0573f37c774b04 # v4
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
T-Task
|
||||
|
||||
- name: Enable automerge
|
||||
uses: peter-evans/enable-pull-request-automerge@684fed02ccc9b5eefcf7d40b65b3cd44255bd5bc # v2
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
|
||||
@@ -1,3 +1,60 @@
|
||||
Changes in [25.2.0-rc.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.2.0-rc.4) (2023-05-16)
|
||||
============================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate device methods in MatrixClient ([\#3357](https://github.com/matrix-org/matrix-js-sdk/pull/3357)).
|
||||
|
||||
## ✨ Features
|
||||
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
|
||||
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
|
||||
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
|
||||
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
|
||||
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
|
||||
|
||||
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
|
||||
|
||||
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
|
||||
|
||||
## ✨ Features
|
||||
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
|
||||
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
|
||||
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
|
||||
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
|
||||
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
|
||||
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
|
||||
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
|
||||
|
||||
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Change `Store.save()` to return a `Promise` ([\#3221](https://github.com/matrix-org/matrix-js-sdk/pull/3221)). Contributed by @texuf.
|
||||
|
||||
## ✨ Features
|
||||
* Add typedoc-plugin-mdn-links ([\#3292](https://github.com/matrix-org/matrix-js-sdk/pull/3292)).
|
||||
* Annotate events with executed push rule ([\#3284](https://github.com/matrix-org/matrix-js-sdk/pull/3284)). Contributed by @kerryarchibald.
|
||||
* Element-R: pass device list change notifications into rust crypto-sdk ([\#3254](https://github.com/matrix-org/matrix-js-sdk/pull/3254)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
|
||||
* Support for MSC3882 revision 1 ([\#3228](https://github.com/matrix-org/matrix-js-sdk/pull/3228)). Contributed by @hughns.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix screen sharing on Firefox 113 ([\#3282](https://github.com/matrix-org/matrix-js-sdk/pull/3282)). Contributed by @tulir.
|
||||
* Retry processing potential poll events after decryption ([\#3246](https://github.com/matrix-org/matrix-js-sdk/pull/3246)). Fixes vector-im/element-web#24568.
|
||||
* Element-R: handle events which arrive before their keys ([\#3230](https://github.com/matrix-org/matrix-js-sdk/pull/3230)). Fixes vector-im/element-web#24489.
|
||||
|
||||
Changes in [24.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.1.0) (2023-04-11)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Config } from "jest";
|
||||
import { env } from "process";
|
||||
|
||||
const config: Config = {
|
||||
testEnvironment: "node",
|
||||
testMatch: ["<rootDir>/spec/**/*.spec.{js,ts}"],
|
||||
setupFilesAfterEnv: ["<rootDir>/spec/setupTests.ts"],
|
||||
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
|
||||
coverageReporters: ["text-summary", "lcov"],
|
||||
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
||||
};
|
||||
|
||||
// if we're running under GHA, enable the GHA reporter
|
||||
if (env["GITHUB_ACTIONS"] !== undefined) {
|
||||
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
|
||||
|
||||
// if we're running against the develop branch, also enable the slow test reporter
|
||||
if (env["GITHUB_REF"] == "refs/heads/develop") {
|
||||
reporters.push("<rootDir>/spec/slowReporter.js");
|
||||
}
|
||||
config.reporters = reporters;
|
||||
}
|
||||
|
||||
export default config;
|
||||
+13
-26
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "24.1.0",
|
||||
"version": "25.2.0-rc.4",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -55,7 +55,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -101,13 +101,13 @@
|
||||
"debug": "^4.3.4",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^40.0.0",
|
||||
"eslint-plugin-jsdoc": "^43.0.6",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^46.0.0",
|
||||
@@ -119,30 +119,17 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.4",
|
||||
"rimraf": "^4.0.0",
|
||||
"prettier": "2.8.8",
|
||||
"rimraf": "^5.0.0",
|
||||
"terser": "^5.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.23.20",
|
||||
"typedoc-plugin-missing-exports": "^1.0.0",
|
||||
"typescript": "^4.5.3"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": [
|
||||
"<rootDir>/spec/**/*.spec.{js,ts}"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/spec/setupTests.ts"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.{js,ts}"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text-summary",
|
||||
"lcov"
|
||||
],
|
||||
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
|
||||
"typedoc": "^0.24.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
"typedoc-plugin-missing-exports": "^2.0.0",
|
||||
"typedoc-plugin-versions": "^0.2.3",
|
||||
"typedoc-plugin-versions-cli": "^0.1.12",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
"outputDirectory": "coverage",
|
||||
|
||||
@@ -15,4 +15,4 @@ for line in sys.stdin:
|
||||
break
|
||||
found_first_header = True
|
||||
elif not re.match(r"^=+$", line) and len(line) > 0:
|
||||
print line
|
||||
print(line)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
|
||||
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
/**
|
||||
* Integration tests for cross-signing functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("bootstrapCrossSigning (before initialsync completes)", () => {
|
||||
oldBackendOnly("publishes keys if none were yet published", async () => {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
{ url: "path:/_matrix/client/unstable/keys/device_signing/upload", name: "upload-keys" },
|
||||
{},
|
||||
);
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
await aliceClient.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
});
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
// master key is signed by the device
|
||||
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
|
||||
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
|
||||
// ssk and usk are signed by the master key
|
||||
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
|
||||
|
||||
// check the publish call
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// there should be a signature for our device, by our self-signing key.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+208
-38
@@ -48,6 +48,8 @@ import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
|
||||
const ROOM_ID = "!room:id";
|
||||
|
||||
@@ -624,10 +626,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(decryptedEvent.getContent().body).toEqual("42");
|
||||
});
|
||||
|
||||
oldBackendOnly("Alice receives a megolm message before the session keys", async () => {
|
||||
it("Alice receives a megolm message before the session keys", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
|
||||
// https://github.com/vector-im/element-web/issues/2273
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
// if we're using the old crypto impl, stub out some methods in the device manager.
|
||||
@@ -667,7 +667,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await testUtils.awaitDecryption(event);
|
||||
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
// now she gets the room_key event
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
@@ -678,20 +682,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
let decryptedEvent: MatrixEvent;
|
||||
if (event.getContent().msgtype != "m.bad.encrypted") {
|
||||
decryptedEvent = event;
|
||||
} else {
|
||||
decryptedEvent = await new Promise<MatrixEvent>((resolve) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev) => {
|
||||
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
|
||||
resolve(ev);
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(decryptedEvent.getContent().body).toEqual("42");
|
||||
await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent().body).toEqual("42");
|
||||
});
|
||||
|
||||
it("Alice gets a second room_key message", async () => {
|
||||
@@ -1947,51 +1939,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function listenToUpload(): Promise<number> {
|
||||
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const keysCount = Object.keys(content?.one_time_keys || {}).length;
|
||||
if (keysCount) resolve(keysCount);
|
||||
const fallbackKeysCount = Object.keys(content?.fallback_keys || {}).length;
|
||||
if (keysCount) resolve({ keysCount, fallbackKeysCount });
|
||||
return {
|
||||
one_time_key_counts: {
|
||||
// The matrix client does `/upload` requests until 50 keys are uploaded
|
||||
// We return here 60 to avoid the `/upload` request loop
|
||||
signed_curve25519: keysCount ? 60 : keysCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// catch both r0 and v3 variants
|
||||
fetchMock.post(
|
||||
new URL("/_matrix/client/r0/keys/upload", aliceClient.getHomeserverUrl()).toString(),
|
||||
listener,
|
||||
{
|
||||
for (const path of ["/_matrix/client/r0/keys/upload", "/_matrix/client/v3/keys/upload"]) {
|
||||
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener, {
|
||||
// These routes are already defined in the E2EKeyReceiver
|
||||
// We want to overwrite the behaviour of the E2EKeyReceiver
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
fetchMock.post(
|
||||
new URL("/_matrix/client/v3/keys/upload", aliceClient.getHomeserverUrl()).toString(),
|
||||
listener,
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("should make key upload request after sync", async () => {
|
||||
let uploadPromise = listenToUpload();
|
||||
let uploadPromise = awaitKeyUploadRequest();
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([]));
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
expect(await uploadPromise).toBeGreaterThan(0);
|
||||
|
||||
uploadPromise = listenToUpload();
|
||||
// Verify that `/upload` is called on Alice's homesever
|
||||
const { keysCount, fallbackKeysCount } = await uploadPromise;
|
||||
expect(keysCount).toBeGreaterThan(0);
|
||||
expect(fallbackKeysCount).toBe(0);
|
||||
|
||||
uploadPromise = awaitKeyUploadRequest();
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 2,
|
||||
device_one_time_keys_count: { signed_curve25519: 0 },
|
||||
device_unused_fallback_key_types: [],
|
||||
});
|
||||
|
||||
// Advance local date to 2 minutes
|
||||
@@ -2000,7 +1992,185 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
expect(await uploadPromise).toBeGreaterThan(0);
|
||||
// After we set device_one_time_keys_count to 0
|
||||
// a `/upload` is expected
|
||||
const res = await uploadPromise;
|
||||
expect(res.keysCount).toBeGreaterThan(0);
|
||||
expect(res.fallbackKeysCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
device_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
EBMMPAFOPU: {
|
||||
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
device_id: "EBMMPAFOPU",
|
||||
keys: {
|
||||
"curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w",
|
||||
"ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:EBMMPAFOPU":
|
||||
"XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw",
|
||||
},
|
||||
},
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
unsigned: {
|
||||
device_display_name: "display name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
failures: {},
|
||||
master_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:UKAQMJSJZC":
|
||||
"q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
self_signing_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
"ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo":
|
||||
"YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user_signing_keys: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
user_id: "@testing_florian1:matrix.org",
|
||||
usage: ["user_signing"],
|
||||
keys: {
|
||||
"ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs":
|
||||
"Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs",
|
||||
},
|
||||
signatures: {
|
||||
"@testing_florian1:matrix.org": {
|
||||
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
|
||||
"WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function awaitKeyQueryRequest(): Promise<Record<string, []>> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
// Resolve with request payload
|
||||
resolve(content.device_keys);
|
||||
|
||||
// Return response of `/keys/query`
|
||||
return queryResponseBody;
|
||||
};
|
||||
|
||||
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
|
||||
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Download uncached keys for known user", async () => {
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@testing_florian1:matrix.org";
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
|
||||
|
||||
// Wait for `/keys/query` to be called
|
||||
const deviceKeysPayload = await queryPromise;
|
||||
|
||||
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
|
||||
expect(devicesInfo.get(user)?.size).toBe(1);
|
||||
|
||||
// Convert the expected device to IDevice and check
|
||||
expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual(
|
||||
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU),
|
||||
);
|
||||
});
|
||||
|
||||
it("Download uncached keys for unknown user", async () => {
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@bob:xyz";
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
|
||||
|
||||
// Wait for `/keys/query` to be called
|
||||
const deviceKeysPayload = await queryPromise;
|
||||
|
||||
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
|
||||
// The old crypto has an empty map for `@bob:xyz`
|
||||
// The new crypto does not have the `@bob:xyz` entry in `devicesInfo`
|
||||
expect(devicesInfo.get(user)?.size).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Get devices from tacked users", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
const queryPromise = awaitKeyQueryRequest();
|
||||
|
||||
const user = "@testing_florian1:matrix.org";
|
||||
// `user` will be added to the room
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"]));
|
||||
|
||||
// Advance local date to 2 minutes
|
||||
// The old crypto only runs the upload every 60 seconds
|
||||
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
|
||||
jest.runAllTimers();
|
||||
// Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
|
||||
await flushPromises();
|
||||
|
||||
// Wait for alice to query `user` keys
|
||||
await queryPromise;
|
||||
|
||||
// Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
|
||||
jest.runAllTimers();
|
||||
// Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
|
||||
// It will add `@testing_florian1:matrix.org` devices to the DeviceList
|
||||
await flushPromises();
|
||||
|
||||
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]);
|
||||
|
||||
// We should only have the `user` in it
|
||||
expect(devicesInfo.size).toBe(1);
|
||||
// We are expecting only the EBMMPAFOPU device
|
||||
expect(devicesInfo.get(user)!.size).toBe(1);
|
||||
expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual(
|
||||
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
Filter,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
RelationType,
|
||||
Room,
|
||||
} from "../../src/matrix";
|
||||
import { logger } from "../../src/logger";
|
||||
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
|
||||
import { TestClient } from "../TestClient";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
|
||||
const userId = "@alice:localhost";
|
||||
const userName = "Alice";
|
||||
@@ -1164,6 +1167,117 @@ describe("MatrixClient event timelines", function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ensure thread events don't get reordered with recursive relations", async () => {
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY2 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
const THREAD_ROOT_REACTION = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: THREAD_ROOT.event_id!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
THREAD_ROOT_REACTION.localTimestamp += 2000;
|
||||
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY3 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 3",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY3.localTimestamp += 3000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 3,
|
||||
latest_event: THREAD_REPLY3.event,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
limit: 3,
|
||||
recurse: true,
|
||||
}),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("paginateEventTimeline for thread list timeline", function () {
|
||||
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
|
||||
|
||||
@@ -1847,7 +1961,10 @@ describe("MatrixClient event timelines", function () {
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
from: "start_token",
|
||||
}),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import HttpBackend from "matrix-mock-request";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
|
||||
@@ -24,6 +25,7 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { IFilterDefinition } from "../../src/filter";
|
||||
import { ISearchResults } from "../../src/@types/search";
|
||||
import { IStore } from "../../src/store";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("MatrixClient", function () {
|
||||
const userId = "@alice:localhost";
|
||||
@@ -1127,22 +1129,51 @@ describe("MatrixClient", function () {
|
||||
|
||||
describe("requestLoginToken", () => {
|
||||
it("should hit the expected API endpoint with UIA", async () => {
|
||||
httpBackend!
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
const response = {};
|
||||
const uiaData = {};
|
||||
const prom = client!.requestLoginToken(uiaData);
|
||||
httpBackend!
|
||||
.when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData })
|
||||
.when("POST", "/unstable/org.matrix.msc3882/login/get_token", { auth: uiaData })
|
||||
.respond(200, response);
|
||||
await httpBackend!.flush("");
|
||||
expect(await prom).toStrictEqual(response);
|
||||
});
|
||||
|
||||
it("should hit the expected API endpoint without UIA", async () => {
|
||||
const response = {};
|
||||
httpBackend!
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client!.requestLoginToken();
|
||||
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
await httpBackend!.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
});
|
||||
|
||||
it("should hit the r1 endpoint when capability is disabled", async () => {
|
||||
httpBackend!
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: false } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client!.requestLoginToken();
|
||||
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
await httpBackend!.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
});
|
||||
|
||||
it("should hit the r0 endpoint for fallback", async () => {
|
||||
httpBackend!.when("GET", "/capabilities").respond(200, {});
|
||||
const response = { login_token: "xyz", expires_in: 5 };
|
||||
const prom = client!.requestLoginToken();
|
||||
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/token", {}).respond(200, response);
|
||||
await httpBackend!.flush("");
|
||||
expect(await prom).toStrictEqual(response);
|
||||
// check that expires_in has been populated for compatibility with r1
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in_ms: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1383,6 +1414,42 @@ describe("MatrixClient", function () {
|
||||
await client!.uploadKeys();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCryptoTrustCrossSignedDevices", () => {
|
||||
it("should throw if e2e is disabled", () => {
|
||||
expect(() => client!.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("should proxy to the crypto backend", async () => {
|
||||
const mockBackend = {
|
||||
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client!["cryptoBackend"] = mockBackend;
|
||||
|
||||
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(true);
|
||||
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
|
||||
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCryptoTrustCrossSignedDevices", () => {
|
||||
it("should throw if e2e is disabled", () => {
|
||||
expect(() => client!.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("should proxy to the crypto backend", async () => {
|
||||
const mockBackend = {
|
||||
setTrustCrossSignedDevices: jest.fn(),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client!["cryptoBackend"] = mockBackend;
|
||||
|
||||
client!.setCryptoTrustCrossSignedDevices(true);
|
||||
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
|
||||
|
||||
client!.setCryptoTrustCrossSignedDevices(false);
|
||||
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
@@ -662,41 +662,30 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("can update device lists", () => {
|
||||
client!.crypto!.processDeviceLists = jest.fn();
|
||||
ext.onResponse({
|
||||
device_lists: {
|
||||
changed: ["@alice:localhost"],
|
||||
left: ["@bob:localhost"],
|
||||
},
|
||||
});
|
||||
// TODO: more assertions?
|
||||
expect(client!.crypto!.processDeviceLists).toHaveBeenCalledWith({
|
||||
changed: ["@alice:localhost"],
|
||||
left: ["@bob:localhost"],
|
||||
});
|
||||
});
|
||||
|
||||
it("can update OTK counts", () => {
|
||||
client!.crypto!.updateOneTimeKeyCount = jest.fn();
|
||||
it("can update OTK counts and unused fallback keys", () => {
|
||||
client!.crypto!.processKeyCounts = jest.fn();
|
||||
ext.onResponse({
|
||||
device_one_time_keys_count: {
|
||||
signed_curve25519: 42,
|
||||
},
|
||||
});
|
||||
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42);
|
||||
ext.onResponse({
|
||||
device_one_time_keys_count: {
|
||||
not_signed_curve25519: 42,
|
||||
// missing field -> default to 0
|
||||
},
|
||||
});
|
||||
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("can update fallback keys", () => {
|
||||
ext.onResponse({
|
||||
device_unused_fallback_key_types: ["signed_curve25519"],
|
||||
});
|
||||
expect(client!.crypto!.getNeedsNewFallback()).toEqual(false);
|
||||
ext.onResponse({
|
||||
device_unused_fallback_key_types: ["not_signed_curve25519"],
|
||||
});
|
||||
expect(client!.crypto!.getNeedsNewFallback()).toEqual(true);
|
||||
expect(client!.crypto!.processKeyCounts).toHaveBeenCalledWith({ signed_curve25519: 42 }, [
|
||||
"signed_curve25519",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1698,7 +1698,7 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
|
||||
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return {
|
||||
promise: new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import DOMException from "domexception";
|
||||
|
||||
global.DOMException = DOMException;
|
||||
global.DOMException = DOMException as typeof global.DOMException;
|
||||
|
||||
jest.mock("../src/http-api/utils", () => ({
|
||||
...jest.requireActual("../src/http-api/utils"),
|
||||
|
||||
@@ -375,17 +375,17 @@ export async function awaitDecryption(
|
||||
// already
|
||||
if (event.getClearContent() !== null) {
|
||||
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
|
||||
logger.log(`${Date.now()} event ${event.getId()} got decryption error; waiting`);
|
||||
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
} else {
|
||||
logger.log(`${Date.now()} event ${event.getId()} is not yet decrypted; waiting`);
|
||||
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev) => {
|
||||
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,8 @@ export class MockRTCPeerConnection {
|
||||
public triggerIncomingDataChannel(): void {
|
||||
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
|
||||
}
|
||||
|
||||
public restartIce(): void {}
|
||||
}
|
||||
|
||||
export class MockRTCRtpSender {
|
||||
|
||||
@@ -1011,7 +1011,6 @@ describe("Crypto", function () {
|
||||
jest.setTimeout(10000);
|
||||
const client = new TestClient("@a:example.com", "dev").client;
|
||||
await client.initCrypto();
|
||||
client.crypto!.getSecretStorageKey = jest.fn().mockResolvedValue(null);
|
||||
client.crypto!.isCrossSigningReady = async () => false;
|
||||
client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
|
||||
client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
|
||||
|
||||
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { MatrixError } from "../../../src/http-api";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
|
||||
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
|
||||
import { CryptoEvent } from "../../../src/crypto";
|
||||
import { IDevice } from "../../../src/crypto/deviceinfo";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
|
||||
|
||||
const PUSH_RULES_RESPONSE: Response = {
|
||||
method: "GET",
|
||||
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
|
||||
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
await func({});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { DeviceVerification } from "../../../src";
|
||||
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
|
||||
|
||||
describe("device-converter", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId = "xcvf";
|
||||
|
||||
// All parameters for DeviceInfo initialization
|
||||
const keys = {
|
||||
[`ed25519:${deviceId}`]: "key1",
|
||||
[`curve25519:${deviceId}`]: "key2",
|
||||
};
|
||||
const algorithms = ["algo1", "algo2"];
|
||||
const verified = DeviceVerification.Verified;
|
||||
const signatures = { [userId]: { [deviceId]: "sign1" } };
|
||||
const displayName = "display name";
|
||||
const unsigned = {
|
||||
device_display_name: displayName,
|
||||
};
|
||||
|
||||
describe("deviceInfoToDevice", () => {
|
||||
it("should convert a DeviceInfo to a Device", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.deviceId).toBe(deviceId);
|
||||
expect(device.userId).toBe(userId);
|
||||
expect(device.verified).toBe(verified);
|
||||
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
|
||||
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
|
||||
expect(device.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it("should add empty signatures", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.signatures.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
import "../../olm-loader";
|
||||
import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { IObject } from "../../../src/crypto/olmlib";
|
||||
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/crypto/SecretStorage";
|
||||
import { MatrixEvent } from "../../../src/models/event";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { makeTestClients } from "./verification/util";
|
||||
@@ -28,7 +27,7 @@ import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { ISignatures } from "../../../src/@types/signed";
|
||||
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
|
||||
import { SecretStorageKeyDescription } from "../../../src/secret-storage";
|
||||
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
|
||||
@@ -374,6 +374,12 @@ describe("SAS verification", function () {
|
||||
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const bobDeviceVerificationStatus = (await alice.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
|
||||
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
|
||||
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(aliceTrust.isTofu()).toBeTruthy();
|
||||
@@ -381,6 +387,17 @@ describe("SAS verification", function () {
|
||||
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
|
||||
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const aliceDeviceVerificationStatus = (await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
|
||||
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const unknownDeviceVerificationStatus = await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "xyz");
|
||||
expect(unknownDeviceVerificationStatus).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("EventTimelineSet", () => {
|
||||
beforeEach(() => {
|
||||
client = utils.mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
room = new Room(roomId, client, userA);
|
||||
eventTimelineSet = new EventTimelineSet(room);
|
||||
eventTimeline = new EventTimeline(eventTimelineSet);
|
||||
|
||||
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DOMException from "domexception";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src";
|
||||
@@ -33,8 +32,6 @@ describe("MatrixHttpApi", () => {
|
||||
|
||||
const DONE = 0;
|
||||
|
||||
global.DOMException = DOMException;
|
||||
|
||||
beforeEach(() => {
|
||||
xhr = {
|
||||
upload: {} as XMLHttpRequestUpload,
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { Mocked, mocked } from "jest-mock";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
|
||||
@@ -50,6 +50,12 @@ import {
|
||||
MatrixScheduler,
|
||||
Method,
|
||||
Room,
|
||||
EventTimelineSet,
|
||||
PushRuleActionName,
|
||||
TweakName,
|
||||
RuleId,
|
||||
IPushRule,
|
||||
ConditionKind,
|
||||
} from "../../src";
|
||||
import { supportsMatrixCall } from "../../src/webrtc/call";
|
||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||
@@ -63,6 +69,8 @@ import { QueryDict } from "../../src/utils";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -2704,4 +2712,295 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
|
||||
describe("SecretStorage wrappers", () => {
|
||||
let mockSecretStorage: Mocked<ServerSideSecretStorageImpl>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn(),
|
||||
hasKey: jest.fn(),
|
||||
isStored: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorageImpl>;
|
||||
client["_secretStorage"] = mockSecretStorage;
|
||||
});
|
||||
|
||||
it("hasSecretStorageKey", async () => {
|
||||
mockSecretStorage.hasKey.mockResolvedValue(false);
|
||||
expect(await client.hasSecretStorageKey("mykey")).toBe(false);
|
||||
expect(mockSecretStorage.hasKey).toHaveBeenCalledWith("mykey");
|
||||
});
|
||||
|
||||
it("isSecretStored", async () => {
|
||||
const mockResult = { key: {} as SecretStorageKeyDescriptionAesV1 };
|
||||
mockSecretStorage.isStored.mockResolvedValue(mockResult);
|
||||
expect(await client.isSecretStored("mysecret")).toBe(mockResult);
|
||||
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("mysecret");
|
||||
});
|
||||
|
||||
it("getDefaultSecretStorageKeyId", async () => {
|
||||
mockSecretStorage.getDefaultKeyId.mockResolvedValue("bzz");
|
||||
expect(await client.getDefaultSecretStorageKeyId()).toEqual("bzz");
|
||||
});
|
||||
|
||||
it("isKeyBackupKeyStored", async () => {
|
||||
mockSecretStorage.isStored.mockResolvedValue(null);
|
||||
expect(await client.isKeyBackupKeyStored()).toBe(null);
|
||||
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("m.megolm_backup.v1");
|
||||
});
|
||||
});
|
||||
|
||||
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
|
||||
describe("Crypto wrappers", () => {
|
||||
describe("exception if no crypto", () => {
|
||||
it("isCrossSigningReady", () => {
|
||||
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", () => {
|
||||
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", () => {
|
||||
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defer to crypto backend", () => {
|
||||
let mockCryptoBackend: Mocked<CryptoBackend>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCryptoBackend = {
|
||||
isCrossSigningReady: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
isSecretStorageReady: jest.fn(),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<CryptoBackend>;
|
||||
client["cryptoBackend"] = mockCryptoBackend;
|
||||
});
|
||||
|
||||
it("isCrossSigningReady", async () => {
|
||||
const testResult = "test";
|
||||
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
|
||||
expect(await client.isCrossSigningReady()).toBe(testResult);
|
||||
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", async () => {
|
||||
const testOpts = {};
|
||||
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
|
||||
await client.bootstrapCrossSigning(testOpts);
|
||||
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
|
||||
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
client["cryptoBackend"] = mockCryptoBackend;
|
||||
const testResult = "test";
|
||||
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
|
||||
expect(await client.isSecretStorageReady()).toBe(testResult);
|
||||
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginateEventTimeline()", () => {
|
||||
describe("notifications timeline", () => {
|
||||
const unsafeNotification = {
|
||||
actions: ["notify"],
|
||||
room_id: "__proto__",
|
||||
event: testUtils.mkMessage({
|
||||
user: "@villain:server.org",
|
||||
room: "!roomId:server.org",
|
||||
msg: "I am nefarious",
|
||||
}),
|
||||
profile_tag: null,
|
||||
read: true,
|
||||
ts: 12345,
|
||||
};
|
||||
|
||||
const goodNotification = {
|
||||
actions: ["notify"],
|
||||
room_id: "!favouriteRoom:server.org",
|
||||
event: new MatrixEvent({
|
||||
sender: "@bob:server.org",
|
||||
room_id: "!roomId:server.org",
|
||||
type: "m.call.invite",
|
||||
content: {},
|
||||
}),
|
||||
profile_tag: null,
|
||||
read: true,
|
||||
ts: 12345,
|
||||
};
|
||||
|
||||
const highlightNotification = {
|
||||
actions: ["notify", { set_tweak: "highlight", value: true }],
|
||||
room_id: "!roomId:server.org",
|
||||
event: testUtils.mkMessage({
|
||||
user: "@bob:server.org",
|
||||
room: "!roomId:server.org",
|
||||
msg: "I am highlighted banana",
|
||||
}),
|
||||
profile_tag: null,
|
||||
read: true,
|
||||
ts: 12345,
|
||||
};
|
||||
|
||||
const setNotifsResponse = (notifications: any[] = []): void => {
|
||||
const response: HttpLookup = {
|
||||
method: "GET",
|
||||
path: "/notifications",
|
||||
data: { notifications: JSON.parse(JSON.stringify(notifications)) },
|
||||
};
|
||||
httpLookups = [response];
|
||||
};
|
||||
|
||||
const callRule: IPushRule = {
|
||||
actions: [PushRuleActionName.Notify],
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: "type",
|
||||
pattern: "m.call.invite",
|
||||
},
|
||||
],
|
||||
default: true,
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.call",
|
||||
};
|
||||
const masterRule: IPushRule = {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
conditions: [],
|
||||
default: true,
|
||||
enabled: false,
|
||||
rule_id: RuleId.Master,
|
||||
};
|
||||
const bananaRule = {
|
||||
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
|
||||
pattern: "banana",
|
||||
rule_id: "banana",
|
||||
default: false,
|
||||
enabled: true,
|
||||
} as IPushRule;
|
||||
const pushRules = {
|
||||
global: {
|
||||
underride: [callRule],
|
||||
override: [masterRule],
|
||||
content: [bananaRule],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
makeClient();
|
||||
|
||||
// this is how notif timeline is set up in react-sdk
|
||||
const notifTimelineSet = new EventTimelineSet(undefined, {
|
||||
timelineSupport: true,
|
||||
pendingEvents: false,
|
||||
});
|
||||
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
|
||||
client.setNotifTimelineSet(notifTimelineSet);
|
||||
|
||||
setNotifsResponse();
|
||||
|
||||
client.setPushRules(pushRules);
|
||||
});
|
||||
|
||||
it("should throw when trying to paginate forwards", async () => {
|
||||
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
|
||||
await expect(
|
||||
async () => await client.paginateEventTimeline(timeline, { backwards: false }),
|
||||
).rejects.toThrow("paginateNotifTimeline can only paginate backwards");
|
||||
});
|
||||
|
||||
it("defaults limit to 30 events", async () => {
|
||||
jest.spyOn(client.http, "authedRequest");
|
||||
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
|
||||
await client.paginateEventTimeline(timeline, { backwards: true });
|
||||
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Get, "/notifications", {
|
||||
limit: "30",
|
||||
only: "highlight",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out unsafe notifications", async () => {
|
||||
setNotifsResponse([unsafeNotification, goodNotification, highlightNotification]);
|
||||
|
||||
const timelineSet = client.getNotifTimelineSet()!;
|
||||
const timeline = timelineSet.getLiveTimeline();
|
||||
await client.paginateEventTimeline(timeline, { backwards: true });
|
||||
|
||||
// badNotification not added to timeline
|
||||
const timelineEvents = timeline.getEvents();
|
||||
expect(timelineEvents.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("sets push details on events and add to timeline", async () => {
|
||||
setNotifsResponse([goodNotification, highlightNotification]);
|
||||
|
||||
const timelineSet = client.getNotifTimelineSet()!;
|
||||
const timeline = timelineSet.getLiveTimeline();
|
||||
await client.paginateEventTimeline(timeline, { backwards: true });
|
||||
|
||||
const [highlightEvent, goodEvent] = timeline.getEvents();
|
||||
expect(highlightEvent.getPushActions()).toEqual({
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: true,
|
||||
},
|
||||
});
|
||||
expect(highlightEvent.getPushDetails().rule).toEqual({
|
||||
...bananaRule,
|
||||
kind: "content",
|
||||
});
|
||||
expect(goodEvent.getPushActions()).toEqual({
|
||||
notify: true,
|
||||
tweaks: {
|
||||
highlight: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushers", () => {
|
||||
const pusher = {
|
||||
app_id: "test",
|
||||
app_display_name: "Test App",
|
||||
data: {},
|
||||
device_display_name: "test device",
|
||||
kind: "http",
|
||||
lang: "en-NZ",
|
||||
pushkey: "1234",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
makeClient();
|
||||
const response: HttpLookup = {
|
||||
method: Method.Post,
|
||||
path: "/pushers/set",
|
||||
data: {},
|
||||
};
|
||||
httpLookups = [response];
|
||||
jest.spyOn(client.http, "authedRequest").mockClear();
|
||||
});
|
||||
|
||||
it("should make correct request to set pusher", async () => {
|
||||
const result = await client.setPusher(pusher);
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, pusher);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should make correct request to remove pusher", async () => {
|
||||
const result = await client.removePusher(pusher.pushkey, pusher.app_id);
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, {
|
||||
pushkey: pusher.pushkey,
|
||||
app_id: pusher.app_id,
|
||||
kind: null,
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
|
||||
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
it("should create copies of itself", () => {
|
||||
@@ -216,4 +217,95 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.replyEventId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("push details", () => {
|
||||
const pushRule = {
|
||||
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
|
||||
pattern: "banana",
|
||||
rule_id: "banana",
|
||||
kind: "override",
|
||||
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", () => {
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushDetails(actions, pushRule);
|
||||
|
||||
expect(event.getPushDetails()).toEqual({
|
||||
actions,
|
||||
rule: pushRule,
|
||||
});
|
||||
});
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
|
||||
import { M_POLL_START } from "matrix-events-sdk";
|
||||
|
||||
import { EventType, IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
|
||||
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
|
||||
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
|
||||
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
|
||||
import { Poll } from "../../../src/models/poll";
|
||||
import { isPollEvent, Poll } from "../../../src/models/poll";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -453,4 +456,31 @@ describe("Poll", () => {
|
||||
expect(responses.getRelations()).toEqual([responseEvent]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPollEvent", () => {
|
||||
it("should return »false« for a non-poll event", () => {
|
||||
const messageEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
content: {},
|
||||
user: mockClient.getSafeUserId(),
|
||||
room: room.roomId,
|
||||
});
|
||||
expect(isPollEvent(messageEvent)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([[M_POLL_START.name], [M_POLL_RESPONSE.name], [M_POLL_END.name]])(
|
||||
"should return »true« for a »%s« event",
|
||||
(type: string) => {
|
||||
const pollEvent = mkEvent({
|
||||
event: true,
|
||||
type,
|
||||
content: {},
|
||||
user: mockClient.getSafeUserId(),
|
||||
room: room.roomId,
|
||||
});
|
||||
expect(isPollEvent(pollEvent)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { mockClientMethodsUser } from "../test-utils/client";
|
||||
|
||||
describe("NotificationService", function () {
|
||||
const testUserId = "@ali:matrix.org";
|
||||
@@ -45,9 +46,7 @@ describe("NotificationService", function () {
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: {
|
||||
userId: testUserId,
|
||||
},
|
||||
...mockClientMethodsUser(testUserId),
|
||||
supportsIntentionalMentions: () => true,
|
||||
pushRules: {
|
||||
device: {},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
|
||||
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
describe("ReceiptAccumulator", function () {
|
||||
/*
|
||||
* Note: at the time of writing, the ReceiptAccumulator uses the order of
|
||||
* events from sync as the determinant of which one is most recent. This
|
||||
* is correct, but inconsistent with other areas of the code, since we
|
||||
* don't persist this order, so in other places we are forced to use the
|
||||
* timestamp of events and hope that this matches up.
|
||||
*
|
||||
* The tests in this file provide ts values that are consistent with the
|
||||
* sync order, so they should still pass if we change to using ts to
|
||||
* determine order.
|
||||
*
|
||||
* Ideally, we would keep track of sync order so we can use it everywhere.
|
||||
* This would mean we are consistent with how the homeserver sees receipts
|
||||
* and notifications.
|
||||
*/
|
||||
|
||||
it("Discards previous unthreaded receipts for the same user", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
|
||||
});
|
||||
|
||||
it("Discards previous threaded receipts for the same user in the same thread", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for the same user if they are in different threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for different users", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects last receipt for various users and threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Below, if ts=1, this receipt is going to be superceded by another
|
||||
// with ts=2
|
||||
const receipts = [
|
||||
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
|
||||
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
|
||||
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
|
||||
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
|
||||
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
|
||||
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
|
||||
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
|
||||
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
|
||||
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
|
||||
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
|
||||
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
|
||||
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
|
||||
];
|
||||
|
||||
acc.consumeEphemeralEvents(receipts);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// Only the ts=2 receipts make it through
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
|
||||
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
|
||||
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
|
||||
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const newReceipt = (
|
||||
eventId: string,
|
||||
receiptType: ReceiptType,
|
||||
userId: string,
|
||||
ts: number,
|
||||
threadId: string | undefined = undefined,
|
||||
) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: {
|
||||
[eventId]: {
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: Object.fromEntries(
|
||||
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
|
||||
return [
|
||||
eventId,
|
||||
{
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -38,6 +38,7 @@ function makeMockClient(opts: {
|
||||
deviceId: string;
|
||||
deviceKey?: string;
|
||||
msc3882Enabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
msc3886Enabled: boolean;
|
||||
devices?: Record<string, Partial<DeviceInfo>>;
|
||||
verificationFunction?: (
|
||||
@@ -58,6 +59,17 @@ function makeMockClient(opts: {
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
capabilities: {
|
||||
"org.matrix.msc3882.get_login_token": {
|
||||
enabled: opts.msc3882Enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
@@ -111,6 +123,7 @@ describe("Rendezvous", function () {
|
||||
deviceId: "DEVICEID",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: true,
|
||||
});
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
@@ -166,7 +179,13 @@ describe("Rendezvous", function () {
|
||||
await aliceRz.close();
|
||||
});
|
||||
|
||||
it("no protocols", async function () {
|
||||
async function testNoProtocols({
|
||||
msc3882Enabled,
|
||||
msc3882r0Only,
|
||||
}: {
|
||||
msc3882Enabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
}) {
|
||||
const aliceTransport = makeTransport("Alice");
|
||||
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
|
||||
transports.push(aliceTransport, bobTransport);
|
||||
@@ -178,8 +197,9 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: false,
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled,
|
||||
msc3882r0Only,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
@@ -218,6 +238,14 @@ describe("Rendezvous", function () {
|
||||
|
||||
await aliceStartProm;
|
||||
await bobStartPromise;
|
||||
}
|
||||
|
||||
it("no protocols - r0", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
|
||||
});
|
||||
|
||||
it("no protocols - r1", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
|
||||
});
|
||||
|
||||
it("new device declines protocol with outcome unsupported", async function () {
|
||||
@@ -233,6 +261,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -291,6 +320,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -349,6 +379,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -409,6 +440,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -477,6 +509,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
devices,
|
||||
deviceKey: "aaaa",
|
||||
|
||||
+174
-5
@@ -19,7 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, PollStartEvent } from "matrix-events-sdk";
|
||||
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, Optional, PollStartEvent } from "matrix-events-sdk";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
@@ -53,6 +53,8 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
|
||||
import { Crypto } from "../../src/crypto";
|
||||
import { mkThread } from "../test-utils/thread";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { IMessageOpts } from "../test-utils/test-utils";
|
||||
|
||||
describe("Room", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -62,9 +64,10 @@ describe("Room", function () {
|
||||
const userD = "@dorothy:bar";
|
||||
let room: Room;
|
||||
|
||||
const mkMessage = () =>
|
||||
const mkMessage = (opts?: Partial<IMessageOpts>) =>
|
||||
utils.mkMessage(
|
||||
{
|
||||
...opts,
|
||||
event: true,
|
||||
user: userA,
|
||||
room: roomId,
|
||||
@@ -112,9 +115,10 @@ describe("Room", function () {
|
||||
room.client,
|
||||
);
|
||||
|
||||
const mkThreadResponse = (root: MatrixEvent) =>
|
||||
const mkThreadResponse = (root: MatrixEvent, opts?: Partial<IMessageOpts>) =>
|
||||
utils.mkEvent(
|
||||
{
|
||||
...opts,
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: userA,
|
||||
@@ -164,6 +168,66 @@ describe("Room", function () {
|
||||
room.client,
|
||||
);
|
||||
|
||||
const addRoomMainAndThreadMessages = (
|
||||
room: Room,
|
||||
tsMain?: number,
|
||||
tsThread?: number,
|
||||
): { mainEvent?: MatrixEvent; threadEvent?: MatrixEvent } => {
|
||||
const result: { mainEvent?: MatrixEvent; threadEvent?: MatrixEvent } = {};
|
||||
|
||||
if (tsMain) {
|
||||
result.mainEvent = mkMessage({ ts: tsMain });
|
||||
room.addLiveEvents([result.mainEvent]);
|
||||
}
|
||||
|
||||
if (tsThread) {
|
||||
const { rootEvent, thread } = mkThread({
|
||||
room,
|
||||
client: new TestClient().client,
|
||||
authorId: "@bob:example.org",
|
||||
participantUserIds: ["@bob:example.org"],
|
||||
});
|
||||
result.threadEvent = mkThreadResponse(rootEvent, { ts: tsThread });
|
||||
thread.liveTimeline.addEvent(result.threadEvent, { toStartOfTimeline: true });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const addRoomThreads = (
|
||||
room: Room,
|
||||
thread1EventTs: Optional<number>,
|
||||
thread2EventTs: Optional<number>,
|
||||
): { thread1?: Thread; thread2?: Thread } => {
|
||||
const result: { thread1?: Thread; thread2?: Thread } = {};
|
||||
|
||||
if (thread1EventTs !== null) {
|
||||
const { rootEvent: thread1RootEvent, thread: thread1 } = mkThread({
|
||||
room,
|
||||
client: new TestClient().client,
|
||||
authorId: "@bob:example.org",
|
||||
participantUserIds: ["@bob:example.org"],
|
||||
});
|
||||
const thread1Event = mkThreadResponse(thread1RootEvent, { ts: thread1EventTs });
|
||||
thread1.liveTimeline.addEvent(thread1Event, { toStartOfTimeline: true });
|
||||
result.thread1 = thread1;
|
||||
}
|
||||
|
||||
if (thread2EventTs !== null) {
|
||||
const { rootEvent: thread2RootEvent, thread: thread2 } = mkThread({
|
||||
room,
|
||||
client: new TestClient().client,
|
||||
authorId: "@bob:example.org",
|
||||
participantUserIds: ["@bob:example.org"],
|
||||
});
|
||||
const thread2Event = mkThreadResponse(thread2RootEvent, { ts: thread2EventTs });
|
||||
thread2.liveTimeline.addEvent(thread2Event, { toStartOfTimeline: true });
|
||||
result.thread2 = thread2;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
room = new Room(roomId, new TestClient(userA, "device").client, userA);
|
||||
// mock RoomStates
|
||||
@@ -171,6 +235,8 @@ describe("Room", function () {
|
||||
room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState");
|
||||
// @ts-ignore
|
||||
room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState");
|
||||
|
||||
jest.spyOn(logger, "warn");
|
||||
});
|
||||
|
||||
describe("getCreator", () => {
|
||||
@@ -3261,7 +3327,7 @@ describe("Room", function () {
|
||||
expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance);
|
||||
});
|
||||
|
||||
it("adds related events to poll models", async () => {
|
||||
it("adds related events to poll models and log errors", async () => {
|
||||
const pollStartEvent = makePollStart("1");
|
||||
const pollStartEvent2 = makePollStart("2");
|
||||
const events = [pollStartEvent, pollStartEvent2];
|
||||
@@ -3274,6 +3340,7 @@ describe("Room", function () {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messageEvent = new MatrixEvent({
|
||||
type: "m.room.messsage",
|
||||
content: {
|
||||
@@ -3281,6 +3348,19 @@ describe("Room", function () {
|
||||
},
|
||||
});
|
||||
|
||||
const errorEvent = new MatrixEvent({
|
||||
type: M_POLL_START.name,
|
||||
content: {
|
||||
text: "Error!!!!",
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("Test error");
|
||||
|
||||
mocked(client.decryptEventIfNeeded).mockImplementation(async (event: MatrixEvent) => {
|
||||
if (event === errorEvent) throw error;
|
||||
});
|
||||
|
||||
// init poll
|
||||
await room.processPollEvents(events);
|
||||
|
||||
@@ -3289,7 +3369,7 @@ describe("Room", function () {
|
||||
jest.spyOn(poll, "onNewRelation");
|
||||
jest.spyOn(poll2, "onNewRelation");
|
||||
|
||||
await room.processPollEvents([pollResponseEvent, messageEvent]);
|
||||
await room.processPollEvents([errorEvent, messageEvent, pollResponseEvent]);
|
||||
|
||||
// only called for relevant event
|
||||
expect(poll.onNewRelation).toHaveBeenCalledTimes(1);
|
||||
@@ -3297,6 +3377,32 @@ describe("Room", function () {
|
||||
|
||||
// only called on poll with relation
|
||||
expect(poll2.onNewRelation).not.toHaveBeenCalled();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith("Error processing poll event", errorEvent.getId(), error);
|
||||
});
|
||||
|
||||
it("should retry on decryption", async () => {
|
||||
const pollStartEventId = "poll1";
|
||||
const pollStartEvent = makePollStart(pollStartEventId);
|
||||
// simulate decryption failure
|
||||
const isDecryptionFailureSpy = jest.spyOn(pollStartEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
|
||||
await room.processPollEvents([pollStartEvent]);
|
||||
// do not expect a poll to show up for the room
|
||||
expect(room.polls.get(pollStartEventId)).toBeUndefined();
|
||||
|
||||
// now emit a Decrypted event but keep the decryption failure
|
||||
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
|
||||
// still do not expect a poll to show up for the room
|
||||
expect(room.polls.get(pollStartEventId)).toBeUndefined();
|
||||
|
||||
// clear decryption failure and emit a Decrypted event again
|
||||
isDecryptionFailureSpy.mockRestore();
|
||||
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
|
||||
|
||||
// the poll should now show up in the room's polls
|
||||
const poll = room.polls.get(pollStartEventId);
|
||||
expect(poll?.pollId).toBe(pollStartEventId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3432,4 +3538,67 @@ describe("Room", function () {
|
||||
expect(room.findPredecessor()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastLiveEvent", () => {
|
||||
let lastEventInMainTimeline: MatrixEvent;
|
||||
let lastEventInThread: MatrixEvent;
|
||||
|
||||
it("when there are no events, it should return undefined", () => {
|
||||
expect(room.getLastLiveEvent()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("when there is only an event in the main timeline and there are no threads, it should return the last event from the main timeline", () => {
|
||||
lastEventInMainTimeline = addRoomMainAndThreadMessages(room, 23).mainEvent!;
|
||||
room.addLiveEvents([lastEventInMainTimeline]);
|
||||
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
|
||||
});
|
||||
|
||||
it("when there is no event in the room live timeline but in a thread, it should return the last event from the thread", () => {
|
||||
lastEventInThread = addRoomMainAndThreadMessages(room, undefined, 42).threadEvent!;
|
||||
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
|
||||
});
|
||||
|
||||
describe("when there are events in both, the main timeline and threads", () => {
|
||||
it("and the last event is in a thread, it should return the last event from the thread", () => {
|
||||
lastEventInThread = addRoomMainAndThreadMessages(room, 23, 42).threadEvent!;
|
||||
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
|
||||
});
|
||||
|
||||
it("and the last event is in the main timeline, it should return the last event from the main timeline", () => {
|
||||
lastEventInMainTimeline = addRoomMainAndThreadMessages(room, 42, 23).mainEvent!;
|
||||
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastThread", () => {
|
||||
it("when there is no thread, it should return undefined", () => {
|
||||
expect(room.getLastThread()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("when there is only one thread, it should return this one", () => {
|
||||
const { thread1 } = addRoomThreads(room, 23, null);
|
||||
expect(room.getLastThread()).toBe(thread1);
|
||||
});
|
||||
|
||||
it("when there are tho threads, it should return the one with the recent event I", () => {
|
||||
const { thread2 } = addRoomThreads(room, 23, 42);
|
||||
expect(room.getLastThread()).toBe(thread2);
|
||||
});
|
||||
|
||||
it("when there are tho threads, it should return the one with the recent event II", () => {
|
||||
const { thread1 } = addRoomThreads(room, 42, 23);
|
||||
expect(room.getLastThread()).toBe(thread1);
|
||||
});
|
||||
|
||||
it("when there is a thread with the last event ts undefined, it should return the thread with the defined event ts", () => {
|
||||
const { thread2 } = addRoomThreads(room, undefined, 23);
|
||||
expect(room.getLastThread()).toBe(thread2);
|
||||
});
|
||||
|
||||
it("when the last event ts of all threads is undefined, it should return the last added thread", () => {
|
||||
const { thread2 } = addRoomThreads(room, undefined, undefined);
|
||||
expect(room.getLastThread()).toBe(thread2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
KeysUploadRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
SigningKeysUploadRequest,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
@@ -80,6 +81,12 @@ describe("OutgoingRequestProcessor", () => {
|
||||
"https://example.com/_matrix/client/v3/keys/signatures/upload",
|
||||
],
|
||||
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
[
|
||||
"SigningKeysUploadRequest",
|
||||
SigningKeysUploadRequest,
|
||||
"POST",
|
||||
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
|
||||
],
|
||||
];
|
||||
|
||||
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
|
||||
@@ -171,6 +178,40 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests with UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
|
||||
|
||||
// also create a UIA callback
|
||||
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
|
||||
return await makeRequest({ type: "test" });
|
||||
};
|
||||
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("POST", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
|
||||
expect(JSON.parse(req.rawData)).toEqual({ foo: "bar", auth: { type: "test" } });
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("does not explode with unknown requests", async () => {
|
||||
const outgoingRequest = { id: "5678", type: 987 };
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DeviceKeys, DeviceVerification } from "../../../src";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
|
||||
describe("device-converter", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId = "xcvf";
|
||||
|
||||
// All parameters for QueryDevice initialization
|
||||
const keys = {
|
||||
[`ed25519:${deviceId}`]: "key1",
|
||||
[`curve25519:${deviceId}`]: "key2",
|
||||
};
|
||||
const algorithms = ["algo1", "algo2"];
|
||||
const signatures = { [userId]: { [deviceId]: "sign1" } };
|
||||
const displayName = "display name";
|
||||
const unsigned = {
|
||||
device_display_name: displayName,
|
||||
};
|
||||
|
||||
describe("downloadDeviceToJsDevice", () => {
|
||||
it("should convert a QueryDevice to a Device", () => {
|
||||
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
|
||||
keys,
|
||||
algorithms,
|
||||
device_id: deviceId,
|
||||
user_id: userId,
|
||||
signatures,
|
||||
unsigned,
|
||||
};
|
||||
const device = downloadDeviceToJsDevice(queryDevice);
|
||||
|
||||
expect(device.deviceId).toBe(deviceId);
|
||||
expect(device.userId).toBe(userId);
|
||||
expect(device.verified).toBe(DeviceVerification.Unverified);
|
||||
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
|
||||
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
|
||||
expect(device.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it("should add empty signatures", () => {
|
||||
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
|
||||
keys,
|
||||
algorithms,
|
||||
device_id: deviceId,
|
||||
user_id: userId,
|
||||
};
|
||||
const device = downloadDeviceToJsDevice(queryDevice);
|
||||
|
||||
expect(device.signatures.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -22,11 +22,12 @@ import { Mocked } from "jest-mock";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -35,16 +36,15 @@ afterEach(() => {
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
describe(".exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should return a list", async () => {
|
||||
@@ -57,8 +57,7 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should pass through unencrypted to-device messages", async () => {
|
||||
@@ -92,16 +91,26 @@ describe("RustCrypto", () => {
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through one time key counts", async () => {
|
||||
const oneTimeKeyCounts = new Map<string, number>([["signed_curve25519", 50]]);
|
||||
await expect(rustCrypto.preprocessOneTimeKeyCounts(oneTimeKeyCounts)).resolves.not.toBeDefined();
|
||||
});
|
||||
it("isCrossSigningReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should pass through unused fallback keys", async () => {
|
||||
const unusedFallbackKeys = new Set(["signed_curve25519"]);
|
||||
await expect(rustCrypto.preprocessUnusedFallbackKeys(unusedFallbackKeys)).resolves.not.toBeDefined();
|
||||
});
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await rustCrypto.bootstrapCrossSigning({});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe("outgoing requests", () => {
|
||||
@@ -151,7 +160,13 @@ describe("RustCrypto", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
);
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
});
|
||||
|
||||
@@ -216,8 +231,7 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockHttpApi = {} as MatrixClient["http"];
|
||||
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should handle unencrypted events", () => {
|
||||
@@ -240,4 +254,74 @@ describe("RustCrypto", () => {
|
||||
expect(res.encrypted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("get|setTrustCrossSignedDevices", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should be true by default", () => {
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be easily turn-off-and-on-able", () => {
|
||||
rustCrypto.setTrustCrossSignedDevices(false);
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(false);
|
||||
rustCrypto.setTrustCrossSignedDevices(true);
|
||||
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceVerificationStatus", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(() => {
|
||||
olmMachine = {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call getDevice", async () => {
|
||||
olmMachine.getDevice.mockResolvedValue({
|
||||
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
|
||||
isLocallyTrusted: jest.fn().mockReturnValue(false),
|
||||
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
|
||||
} as unknown as RustSdkCryptoJs.Device);
|
||||
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
|
||||
expect(olmMachine.getDevice.mock.calls[0][0].toString()).toEqual("@user:domain");
|
||||
expect(olmMachine.getDevice.mock.calls[0][1].toString()).toEqual("device");
|
||||
expect(res?.crossSigningVerified).toBe(false);
|
||||
expect(res?.localVerified).toBe(false);
|
||||
expect(res?.signedByOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("should return null for unknown device", async () => {
|
||||
olmMachine.getDevice.mockResolvedValue(undefined);
|
||||
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
|
||||
expect(res).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
*
|
||||
* just provides default arguments for initRustCrypto()
|
||||
*/
|
||||
async function makeTestRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }> = {} as MatrixClient["http"],
|
||||
userId: string = TEST_USER,
|
||||
deviceId: string = TEST_DEVICE_ID,
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
Copyright 2019, 2022-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import {
|
||||
AccountDataClient,
|
||||
PassphraseInfo,
|
||||
SecretStorageCallbacks,
|
||||
SecretStorageKeyDescriptionAesV1,
|
||||
SecretStorageKeyDescriptionCommon,
|
||||
ServerSideSecretStorageImpl,
|
||||
trimTrailingEquals,
|
||||
} from "../../src/secret-storage";
|
||||
import { calculateKeyCheck } from "../../src/crypto/aes";
|
||||
import { randomString } from "../../src/randomstring";
|
||||
|
||||
describe("ServerSideSecretStorageImpl", function () {
|
||||
describe(".addKey", function () {
|
||||
it("should allow storing a default key", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
|
||||
|
||||
// it should have made up a 32-character key id
|
||||
expect(result.keyId.length).toEqual(32);
|
||||
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
|
||||
`m.secret_storage.key.${result.keyId}`,
|
||||
result.keyInfo,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow storing a key with an explicit id", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
|
||||
|
||||
// it should have made up a 32-character key id
|
||||
expect(result.keyId).toEqual("myKeyId");
|
||||
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
|
||||
"m.secret_storage.key.myKeyId",
|
||||
result.keyInfo,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow storing a key with a name", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
|
||||
|
||||
expect(result.keyInfo.name).toEqual("mykey");
|
||||
|
||||
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
|
||||
`m.secret_storage.key.${result.keyId}`,
|
||||
result.keyInfo,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow storing a key with a passphrase", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
const passphrase: PassphraseInfo = {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: 125,
|
||||
salt: "saltygoodness",
|
||||
bits: 256,
|
||||
};
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
|
||||
passphrase,
|
||||
});
|
||||
|
||||
expect(result.keyInfo.passphrase).toEqual(passphrase);
|
||||
|
||||
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
|
||||
`m.secret_storage.key.${result.keyId}`,
|
||||
result.keyInfo,
|
||||
);
|
||||
});
|
||||
|
||||
it("should complain about invalid algorithm", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKey", function () {
|
||||
it("should return the specified key", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
|
||||
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
|
||||
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
|
||||
if (eventType === "m.secret_storage.key.my_key") {
|
||||
return storedKey as unknown as T;
|
||||
} else {
|
||||
throw new Error(`unexpected eventType ${eventType}`);
|
||||
}
|
||||
}
|
||||
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
|
||||
|
||||
const result = await secretStorage.getKey("my_key");
|
||||
expect(result).toEqual(["my_key", storedKey]);
|
||||
});
|
||||
|
||||
it("should return the default key if none is specified", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
|
||||
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
|
||||
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
|
||||
if (eventType === "m.secret_storage.default_key") {
|
||||
return { key: "default_key_id" } as unknown as T;
|
||||
} else if (eventType === "m.secret_storage.key.default_key_id") {
|
||||
return storedKey as unknown as T;
|
||||
} else {
|
||||
throw new Error(`unexpected eventType ${eventType}`);
|
||||
}
|
||||
}
|
||||
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
|
||||
|
||||
const result = await secretStorage.getKey();
|
||||
expect(result).toEqual(["default_key_id", storedKey]);
|
||||
});
|
||||
|
||||
it("should return null if the key is not found", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
|
||||
// @ts-ignore
|
||||
accountDataAdapter.getAccountDataFromServer.mockResolvedValue(null);
|
||||
|
||||
const result = await secretStorage.getKey("my_key");
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkKey", function () {
|
||||
it("should return true for a correct key check", async function () {
|
||||
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
|
||||
|
||||
const myKey = new TextEncoder().encode(randomString(32));
|
||||
const { iv, mac } = await calculateKeyCheck(myKey);
|
||||
|
||||
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
|
||||
name: "my key",
|
||||
passphrase: {} as PassphraseInfo,
|
||||
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
|
||||
iv,
|
||||
mac,
|
||||
};
|
||||
|
||||
const result = await secretStorage.checkKey(myKey, keyInfo);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for an incorrect key check", async function () {
|
||||
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
|
||||
|
||||
const { iv, mac } = await calculateKeyCheck(new TextEncoder().encode("badkey"));
|
||||
|
||||
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
|
||||
name: "my key",
|
||||
passphrase: {} as PassphraseInfo,
|
||||
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
|
||||
iv,
|
||||
mac,
|
||||
};
|
||||
|
||||
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should raise for an unknown algorithm", async function () {
|
||||
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
|
||||
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
|
||||
name: "my key",
|
||||
passphrase: {} as PassphraseInfo,
|
||||
algorithm: "bad_alg",
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
};
|
||||
|
||||
await expect(() => secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo)).rejects.toThrow(
|
||||
"Unknown algorithm",
|
||||
);
|
||||
});
|
||||
|
||||
// XXX: really???
|
||||
it("should return true for an absent mac", async function () {
|
||||
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
|
||||
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
|
||||
name: "my key",
|
||||
passphrase: {} as PassphraseInfo,
|
||||
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
|
||||
iv: "iv",
|
||||
mac: "",
|
||||
};
|
||||
|
||||
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("store", () => {
|
||||
it("should ignore keys with unknown algorithm", async function () {
|
||||
const accountDataAdapter = mockAccountDataClient();
|
||||
const mockCallbacks = { getSecretStorageKey: jest.fn() } as Mocked<SecretStorageCallbacks>;
|
||||
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, mockCallbacks);
|
||||
|
||||
// stub out getAccountData to return a key with an unknown algorithm
|
||||
const storedKey = { algorithm: "badalg" } as SecretStorageKeyDescriptionCommon;
|
||||
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
|
||||
if (eventType === "m.secret_storage.key.keyid") {
|
||||
return storedKey as unknown as T;
|
||||
} else {
|
||||
throw new Error(`unexpected eventType ${eventType}`);
|
||||
}
|
||||
}
|
||||
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
|
||||
|
||||
// suppress the expected warning on the console
|
||||
jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
// now attempt the store
|
||||
await secretStorage.store("mysecret", "supersecret", ["keyid"]);
|
||||
|
||||
// we should have stored... nothing
|
||||
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith("mysecret", { encrypted: {} });
|
||||
|
||||
// ... and emitted a warning.
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("unknown algorithm"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimTrailingEquals", () => {
|
||||
it("should strip trailing =", () => {
|
||||
expect(trimTrailingEquals("ab=c===")).toEqual("ab=c");
|
||||
});
|
||||
|
||||
it("should leave strings without trailing = alone", () => {
|
||||
expect(trimTrailingEquals("ab=c")).toEqual("ab=c");
|
||||
});
|
||||
|
||||
it("should leave the empty string alone", () => {
|
||||
const result = trimTrailingEquals("");
|
||||
expect(result).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
function mockAccountDataClient(): Mocked<AccountDataClient> {
|
||||
return {
|
||||
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
|
||||
setAccountData: jest.fn().mockResolvedValue({}),
|
||||
} as unknown as Mocked<AccountDataClient>;
|
||||
}
|
||||
@@ -356,6 +356,77 @@ describe("SyncAccumulator", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("can handle large numbers of identical receipts", () => {
|
||||
const testSize = 1000; // Make this big to check performance (e.g. 10 million ~= 10s)
|
||||
|
||||
const newReceipt = (ts: number) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
"$event1:localhost": {
|
||||
[ReceiptType.Read]: {
|
||||
"@alice:localhost": { ts },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const receipts = [];
|
||||
for (let i = 0; i < testSize; i++) {
|
||||
receipts.push(newReceipt(testSize - i));
|
||||
}
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
ephemeral: {
|
||||
events: receipts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0]).toEqual(newReceipt(1));
|
||||
});
|
||||
|
||||
it("can handle large numbers of receipts for different users and events", () => {
|
||||
const testSize = 100; // Make this big to check performance (e.g. 1 million ~= 10s)
|
||||
|
||||
const newReceipt = (ts: number) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: "!foo:bar",
|
||||
content: {
|
||||
[`$event${ts}:localhost`]: {
|
||||
[ReceiptType.Read]: {
|
||||
[`@alice${ts}:localhost`]: { ts },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const receipts = [];
|
||||
for (let i = 0; i < testSize; i++) {
|
||||
receipts.push(newReceipt(testSize - i));
|
||||
}
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton({
|
||||
ephemeral: {
|
||||
events: receipts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0]["content"]["$event1:localhost"]).toEqual({ "m.read": { "@alice1:localhost": { ts: 1 } } });
|
||||
expect(Object.keys(events[0]["content"]).length).toEqual(testSize);
|
||||
});
|
||||
|
||||
it("should accumulate threaded read receipts", () => {
|
||||
const receipt1 = {
|
||||
type: "m.receipt",
|
||||
@@ -474,6 +545,25 @@ describe("SyncAccumulator", function () {
|
||||
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
|
||||
});
|
||||
|
||||
it("should correctly update summary properties to zero", function () {
|
||||
// When we receive updates of a summary property, the last of which is 0
|
||||
sa.accumulate(
|
||||
createSyncResponseWithSummary({
|
||||
"m.heroes": ["@alice:bar"],
|
||||
"m.invited_member_count": 2,
|
||||
}),
|
||||
);
|
||||
sa.accumulate(
|
||||
createSyncResponseWithSummary({
|
||||
"m.heroes": ["@alice:bar"],
|
||||
"m.invited_member_count": 0,
|
||||
}),
|
||||
);
|
||||
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
|
||||
// Then we give an answer of 0
|
||||
expect(summary["m.invited_member_count"]).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return correctly adjusted age attributes", () => {
|
||||
const delta = 1000;
|
||||
const startingTs = 1000;
|
||||
|
||||
@@ -678,14 +678,14 @@ describe("utils", function () {
|
||||
|
||||
describe("safeSet", () => {
|
||||
it("should set a value", () => {
|
||||
const obj = {};
|
||||
const obj: Record<string, string> = {};
|
||||
safeSet(obj, "testProp", "test value");
|
||||
expect(obj).toEqual({ testProp: "test value" });
|
||||
});
|
||||
|
||||
it.each(["__proto__", "prototype", "constructor"])("should raise an error when setting »%s«", (prop) => {
|
||||
expect(() => {
|
||||
safeSet({}, prop, "teset value");
|
||||
safeSet(<Record<string, string>>{}, prop, "teset value");
|
||||
}).toThrow("Trying to modify prototype or constructor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CallType,
|
||||
CallState,
|
||||
CallParty,
|
||||
CallDirection,
|
||||
} from "../../../src/webrtc/call";
|
||||
import {
|
||||
MCallAnswer,
|
||||
@@ -1652,12 +1653,18 @@ describe("Call", function () {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(call, "hangup");
|
||||
|
||||
await fakeIncomingCall(client, call, "1");
|
||||
|
||||
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
|
||||
|
||||
mockPeerConn.iceConnectionState = "disconnected";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
jest.spyOn(mockPeerConn, "restartIce");
|
||||
});
|
||||
|
||||
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
|
||||
jest.advanceTimersByTime(3 * 1000);
|
||||
expect(mockPeerConn.restartIce).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hang up after being disconnected for 30 seconds", () => {
|
||||
@@ -1665,6 +1672,20 @@ describe("Call", function () {
|
||||
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
|
||||
});
|
||||
|
||||
it("should restart ICE gathering once again after ICE being failed", () => {
|
||||
mockPeerConn.iceConnectionState = "failed";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
expect(mockPeerConn.restartIce).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
|
||||
// @ts-ignore
|
||||
mockPeerConn.restartIce = null;
|
||||
mockPeerConn.iceConnectionState = "failed";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
|
||||
});
|
||||
|
||||
it("should not hangup if we've managed to re-connect", () => {
|
||||
mockPeerConn.iceConnectionState = "connected";
|
||||
mockPeerConn.iceConnectionStateChangeListener!();
|
||||
@@ -1692,4 +1713,110 @@ describe("Call", function () {
|
||||
expect(onReplace).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("should handle glare in negotiation process", () => {
|
||||
beforeEach(async () => {
|
||||
// cut methods not want to test
|
||||
call.hangup = () => null;
|
||||
call.isLocalOnHold = () => true;
|
||||
// @ts-ignore
|
||||
call.updateRemoteSDPStreamMetadata = jest.fn();
|
||||
// @ts-ignore
|
||||
call.getRidOfRTXCodecs = jest.fn();
|
||||
// @ts-ignore
|
||||
call.createAnswer = jest.fn().mockResolvedValue({});
|
||||
// @ts-ignore
|
||||
call.sendVoipEvent = jest.fn();
|
||||
});
|
||||
|
||||
it("and reject remote offer if not polite and have pending local offer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have already a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and not reject remote offer if not polite and do have pending answer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have not a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = false;
|
||||
|
||||
// If we have a setRemoteDescription() answer operation pending, then
|
||||
// we will be "stable" by the time the next setRemoteDescription() is
|
||||
// executed, so we count this being readyForOffer when deciding whether to
|
||||
// ignore the offer.
|
||||
// @ts-ignore
|
||||
call.isSettingRemoteAnswerPending = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
|
||||
// not polite user == CallDirection.Outbound
|
||||
call.direction = CallDirection.Outbound;
|
||||
// have no local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = false;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "stable",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("and if polite do rollback pending local offer", async () => {
|
||||
// polite user == CallDirection.Inbound
|
||||
call.direction = CallDirection.Inbound;
|
||||
// have already a local offer
|
||||
// @ts-ignore
|
||||
call.makingOffer = true;
|
||||
const offerEvent = makeMockEvent("@test:foo", {
|
||||
description: {
|
||||
type: "offer",
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
call.peerConn = {
|
||||
signalingState: "have-local-offer",
|
||||
setRemoteDescription: jest.fn(),
|
||||
};
|
||||
await call.onNegotiateReceived(offerEvent);
|
||||
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,6 +388,10 @@ describe("Group Call", function () {
|
||||
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
|
||||
|
||||
await groupCall.create();
|
||||
|
||||
const deviceCallMap = new Map<string, MatrixCall>();
|
||||
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
|
||||
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
|
||||
});
|
||||
|
||||
it("ignores changes, if we can't get user id of opponent", async () => {
|
||||
@@ -513,8 +517,7 @@ describe("Group Call", function () {
|
||||
await groupCall.setMicrophoneMuted(false);
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(false);
|
||||
|
||||
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -581,7 +584,15 @@ describe("Group Call", function () {
|
||||
});
|
||||
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
|
||||
|
||||
const getUserMediaStreamFlush = Promise.resolve("stream");
|
||||
// @ts-ignore
|
||||
mockCall.cleint = {
|
||||
getMediaHandler: {
|
||||
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
|
||||
},
|
||||
};
|
||||
const mutePromise = groupCall.setMicrophoneMuted(true);
|
||||
await getUserMediaStreamFlush;
|
||||
// we should be muted at this point, before the metadata update has been sent
|
||||
expect(groupCall.isMicrophoneMuted()).toEqual(true);
|
||||
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
|
||||
@@ -888,14 +899,34 @@ describe("Group Call", function () {
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no permission for audio stream", async () => {
|
||||
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
|
||||
new Error("No Permission"),
|
||||
);
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user media stream null", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
|
||||
// @ts-ignore
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
// @ts-ignore
|
||||
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
|
||||
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
|
||||
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when unmuting video with no video device", async () => {
|
||||
const groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
|
||||
@@ -1625,4 +1656,77 @@ describe("Group Call", function () {
|
||||
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collection stats", () => {
|
||||
let groupCall: GroupCall;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
});
|
||||
|
||||
afterAll(() => jest.useRealTimers());
|
||||
|
||||
beforeEach(async () => {
|
||||
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
||||
const mockClient = typedMockClient.typed();
|
||||
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
||||
groupCall = new GroupCall(
|
||||
mockClient,
|
||||
room,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
GroupCallIntent.Prompt,
|
||||
FAKE_CONF_ID,
|
||||
);
|
||||
});
|
||||
it("should be undefined if not get stats", async () => {
|
||||
// @ts-ignore
|
||||
const stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be defined after first access", async () => {
|
||||
groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stats = groupCall.stats;
|
||||
expect(stats).toBeDefined();
|
||||
});
|
||||
|
||||
it("with every number should do nothing if no stats exists.", async () => {
|
||||
groupCall.setGroupCallStatsInterval(0);
|
||||
// @ts-ignore
|
||||
let stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
|
||||
groupCall.setGroupCallStatsInterval(10000);
|
||||
// @ts-ignore
|
||||
stats = groupCall.stats;
|
||||
expect(stats).toBeUndefined();
|
||||
});
|
||||
|
||||
it("with number should stop existing stats", async () => {
|
||||
const stats = groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stop = jest.spyOn(stats, "stop");
|
||||
// @ts-ignore
|
||||
const start = jest.spyOn(stats, "start");
|
||||
groupCall.setGroupCallStatsInterval(0);
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("with number should restart existing stats", async () => {
|
||||
const stats = groupCall.getGroupCallStats();
|
||||
// @ts-ignore
|
||||
const stop = jest.spyOn(stats, "stop");
|
||||
// @ts-ignore
|
||||
const start = jest.spyOn(stats, "start");
|
||||
groupCall.setGroupCallStatsInterval(10000);
|
||||
|
||||
expect(stop).toHaveBeenCalled();
|
||||
expect(start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
|
||||
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
|
||||
|
||||
const GROUP_CALL_ID = "GROUP_ID";
|
||||
const LOCAL_USER_ID = "LOCAL_USER_ID";
|
||||
@@ -67,7 +68,7 @@ describe("GroupCallStats", () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("starting processing as well without stats collectors", async () => {
|
||||
it("starting processing stats as well without stats collectors", async () => {
|
||||
// @ts-ignore
|
||||
stats.processStats = jest.fn();
|
||||
stats.start();
|
||||
@@ -76,12 +77,30 @@ describe("GroupCallStats", () => {
|
||||
expect(stats.processStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("not starting processing stats if interval 0", async () => {
|
||||
const statsDisabled = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, 0);
|
||||
// @ts-ignore
|
||||
statsDisabled.processStats = jest.fn();
|
||||
statsDisabled.start();
|
||||
jest.advanceTimersByTime(TIME_INTERVAL);
|
||||
// @ts-ignore
|
||||
expect(statsDisabled.processStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starting processing and calling the collectors", async () => {
|
||||
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
|
||||
const collector = stats.getStatsReportGatherer("CALL_ID");
|
||||
stats.reports.emitSummaryStatsReport = jest.fn();
|
||||
const summaryStats = {
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 0, muted: 0 },
|
||||
videoTrackSummary: { count: 0, muted: 0 },
|
||||
} as SummaryStats;
|
||||
let processStatsSpy;
|
||||
if (collector) {
|
||||
processStatsSpy = jest.spyOn(collector, "processStats");
|
||||
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
|
||||
stats.start();
|
||||
jest.advanceTimersByTime(TIME_INTERVAL);
|
||||
} else {
|
||||
|
||||
@@ -26,14 +26,14 @@ describe("MediaSsrcHandler", () => {
|
||||
handler = new MediaSsrcHandler();
|
||||
});
|
||||
describe("should parse description", () => {
|
||||
it("and build mid ssrc map", () => {
|
||||
it("and build mid ssrc map", async () => {
|
||||
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
|
||||
expect(handler.getSsrcToMidMap("remote")).toEqual(remoteMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should on find mid by ssrc", () => {
|
||||
it("and return mid if mapping exists.", () => {
|
||||
it("and return mid if mapping exists.", async () => {
|
||||
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
|
||||
expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0");
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("TrackHandler", () => {
|
||||
handler = new MediaTrackHandler(pc);
|
||||
});
|
||||
describe("should get local tracks", () => {
|
||||
it("returns video track", () => {
|
||||
it("returns video track", async () => {
|
||||
expect(handler.getLocalTracks("video")).toEqual([
|
||||
{
|
||||
id: `sender-track-2`,
|
||||
@@ -34,7 +34,7 @@ describe("TrackHandler", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns audio track", () => {
|
||||
it("returns audio track", async () => {
|
||||
expect(handler.getLocalTracks("audio")).toEqual([
|
||||
{
|
||||
id: `sender-track-1`,
|
||||
@@ -45,58 +45,72 @@ describe("TrackHandler", () => {
|
||||
});
|
||||
|
||||
describe("should get local track by mid", () => {
|
||||
it("returns video track", () => {
|
||||
it("returns video track", async () => {
|
||||
expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2");
|
||||
});
|
||||
|
||||
it("returns audio track", () => {
|
||||
it("returns audio track", async () => {
|
||||
expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1");
|
||||
});
|
||||
|
||||
it("returns undefined if not exists", () => {
|
||||
it("returns undefined if not exists", async () => {
|
||||
expect(handler.getLocalTrackIdByMid("3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should get remote track by mid", () => {
|
||||
it("returns video track", () => {
|
||||
it("returns video track", async () => {
|
||||
expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2");
|
||||
});
|
||||
|
||||
it("returns audio track", () => {
|
||||
it("returns audio track", async () => {
|
||||
expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1");
|
||||
});
|
||||
|
||||
it("returns undefined if not exists", () => {
|
||||
it("returns undefined if not exists", async () => {
|
||||
expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should get track by id", () => {
|
||||
it("returns remote track", () => {
|
||||
it("returns remote track", async () => {
|
||||
expect(handler.getTackById("receiver-track-2")).toEqual({
|
||||
id: `receiver-track-2`,
|
||||
kind: "video",
|
||||
} as MediaStreamTrack);
|
||||
});
|
||||
|
||||
it("returns local track", () => {
|
||||
it("returns local track", async () => {
|
||||
expect(handler.getTackById("sender-track-1")).toEqual({
|
||||
id: `sender-track-1`,
|
||||
kind: "audio",
|
||||
} as MediaStreamTrack);
|
||||
});
|
||||
|
||||
it("returns undefined if not exists", () => {
|
||||
it("returns undefined if not exists", async () => {
|
||||
expect(handler.getTackById("sender-track-3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should get simulcast track count", () => {
|
||||
it("returns 2", () => {
|
||||
it("returns 2", async () => {
|
||||
expect(handler.getActiveSimulcastStreams()).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should get Transceiver by Track ID", () => {
|
||||
it("and returns remote Transceiver if exits", async () => {
|
||||
expect(handler.getTransceiverByTrackId(`receiver-track-1`)?.mid).toEqual("1");
|
||||
});
|
||||
|
||||
it("and returns local Transceiver if exits", async () => {
|
||||
expect(handler.getTransceiverByTrackId(`sender-track-2`)?.mid).toEqual("2");
|
||||
});
|
||||
|
||||
it("returns undefined if Transceiver not exits", async () => {
|
||||
expect(handler.getTransceiverByTrackId("22")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockTransceiver = (mid: string, kind: "video" | "audio"): RTCRtpTransceiver => {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("MediaTrackStatsHandler", () => {
|
||||
});
|
||||
});
|
||||
describe("should find local video track stats", () => {
|
||||
it("and returns stats if `trackIdentifier` exists in report", () => {
|
||||
it("and returns stats if `trackIdentifier` exists in report", async () => {
|
||||
const report = { trackIdentifier: "2222" };
|
||||
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
|
||||
});
|
||||
@@ -75,9 +75,22 @@ describe("MediaTrackStatsHandler", () => {
|
||||
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
|
||||
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
|
||||
});
|
||||
it("and returns undefined if needed property not existing", () => {
|
||||
it("and returns undefined if needed property not existing", async () => {
|
||||
const report = {};
|
||||
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should find a Transceiver by Track id", () => {
|
||||
it("and returns undefined if Transceiver not existing", async () => {
|
||||
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(undefined);
|
||||
expect(statsHandler.findTransceiverByTrackId("12")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("and returns Transceiver if existing", async () => {
|
||||
const ts = {} as RTCRtpTransceiver;
|
||||
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(ts);
|
||||
expect(statsHandler.findTransceiverByTrackId("12")).toEqual(ts);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,17 @@ describe("StatsReportBuilder", () => {
|
||||
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
|
||||
]),
|
||||
},
|
||||
jitter: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", 0.1],
|
||||
["REMOTE_VIDEO_TRACK_ID", 50],
|
||||
]),
|
||||
audioConcealment: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", { concealedAudio: 3000, totalAudioDuration: 3000 * 20 }],
|
||||
]),
|
||||
totalAudioConcealment: {
|
||||
concealedAudio: 3000,
|
||||
totalAudioDuration: (1 / 0.05) * 3000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,6 +110,8 @@ describe("StatsReportBuilder", () => {
|
||||
remoteAudioTrack.setCodec("opus");
|
||||
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
|
||||
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
|
||||
remoteAudioTrack.setJitter(0.1);
|
||||
remoteAudioTrack.setAudioConcealment(3000, 3000 * 20);
|
||||
|
||||
localVideoTrack.setCodec("v8");
|
||||
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
|
||||
@@ -111,5 +124,6 @@ describe("StatsReportBuilder", () => {
|
||||
remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 });
|
||||
remoteVideoTrack.setFramerate(60);
|
||||
remoteVideoTrack.setResolution({ width: 1080, height: 960 });
|
||||
remoteVideoTrack.setJitter(50);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -14,7 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "../../../../src/webrtc/stats/statsReport";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
ConnectionStatsReport,
|
||||
StatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "../../../../src/webrtc/stats/statsReport";
|
||||
|
||||
describe("StatsReportEmitter", () => {
|
||||
let emitter: StatsReportEmitter;
|
||||
@@ -45,4 +50,16 @@ describe("StatsReportEmitter", () => {
|
||||
emitter.emitConnectionStatsReport(report);
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit and receive SummaryStatsReport", async () => {
|
||||
const report = {} as SummaryStatsReport;
|
||||
return new Promise((resolve, _) => {
|
||||
emitter.on(StatsReport.SUMMARY_STATS, (r) => {
|
||||
expect(r).toBe(report);
|
||||
resolve(null);
|
||||
return;
|
||||
});
|
||||
emitter.emitSummaryStatsReport(report);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,33 @@ describe("StatsReportGatherer", () => {
|
||||
describe("on process stats", () => {
|
||||
it("if active calculate stats reports", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
getStats.mockResolvedValue({} as RTCStatsReport);
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("if not active do not calculate stats reports", async () => {
|
||||
@@ -60,7 +84,27 @@ describe("StatsReportGatherer", () => {
|
||||
// @ts-ignore
|
||||
getStats.mockReturnValue({});
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toBeFalsy();
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
describe("SummaryStatsReporter", () => {
|
||||
let reporter: SummaryStatsReporter;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
emitter = new StatsReportEmitter();
|
||||
emitter.emitSummaryStatsReport = jest.fn();
|
||||
reporter = new SummaryStatsReporter(emitter);
|
||||
});
|
||||
|
||||
describe("build Summary Stats Report", () => {
|
||||
it("should do nothing if summary list empty", async () => {
|
||||
reporter.build([]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger new summary report", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 13,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 13,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 5,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 10,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 15,
|
||||
receivedAudioMedia: 6,
|
||||
receivedVideoMedia: 9,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0.5,
|
||||
percentageReceivedAudioMedia: 0.5,
|
||||
percentageReceivedVideoMedia: 0.75,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0.0375,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received video Media, although video was not received, but because video muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no video Media, because only on video was muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 2,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no audio Media, although audio not received and audio muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 100,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 100,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 0,
|
||||
percentageReceivedAudioMedia: 0,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should find max jitter and max packet loss", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 40,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received video Media, if no audio track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 10,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received audio Media, if no video track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 22,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("as received no media at all, as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
peerConnections: 1,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -129,4 +129,277 @@ describe("TrackStatsReporter", () => {
|
||||
expect(trackStats.getLoss()).toEqual({ packetsTotal: 280, packetsLost: 80, isDownloadStream: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("should set state of a TrackStats", () => {
|
||||
it("to not alive if Transceiver undefined", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
it("to not alive if Transceiver has no local track", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
const ts = {
|
||||
sender: {
|
||||
track: null,
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
it("to alive if Transceiver remote and track is alive", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats.alive = false;
|
||||
const ts = {
|
||||
receiver: {
|
||||
track: {
|
||||
readyState: "live",
|
||||
enabled: false,
|
||||
muted: false,
|
||||
} as MediaStreamTrack,
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
it("to alive if Transceiver local and track is live", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
trackStats.alive = false;
|
||||
const ts = {
|
||||
sender: {
|
||||
track: {
|
||||
readyState: "live",
|
||||
enabled: false,
|
||||
muted: false,
|
||||
} as MediaStreamTrack,
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
it("to not alive if Transceiver track is ended", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
const ts = {
|
||||
receiver: {
|
||||
track: {
|
||||
readyState: "ended",
|
||||
enabled: false,
|
||||
muted: false,
|
||||
} as MediaStreamTrack,
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
it("to not alive and muted if Transceiver track is live and muted", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
const ts = {
|
||||
receiver: {
|
||||
track: {
|
||||
readyState: "live",
|
||||
enabled: false,
|
||||
muted: true,
|
||||
} as MediaStreamTrack,
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
expect(trackStats.muted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should build Track Summary", () => {
|
||||
it("and returns empty summary if stats list empty", async () => {
|
||||
const summary = TrackStatsReporter.buildTrackSummary([]);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary if stats list not empty and ignore local summery", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and count muted if alive", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[5].muted = true;
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 1,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and ignore muted if not alive", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[1].alive = false;
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("and returns summary and build max jitter, packet loss and audio conealment", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
// video remote
|
||||
trackStatsList[1].setJitter(12);
|
||||
trackStatsList[4].setJitter(66);
|
||||
trackStatsList[6].setJitter(1);
|
||||
trackStatsList[1].setLoss({ packetsLost: 55, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[4].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[6].setLoss({ packetsLost: 1, packetsTotal: 0, isDownloadStream: true });
|
||||
// audio remote
|
||||
trackStatsList[2].setJitter(1);
|
||||
trackStatsList[5].setJitter(15);
|
||||
trackStatsList[2].setLoss({ packetsLost: 5, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[5].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
|
||||
trackStatsList[2].setAudioConcealment(220, 2000);
|
||||
trackStatsList[5].setAudioConcealment(180, 2000);
|
||||
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
muted: 0,
|
||||
maxJitter: 15,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 400,
|
||||
totalAudio: 4000,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 3,
|
||||
muted: 0,
|
||||
maxJitter: 66,
|
||||
maxPacketLoss: 55,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("should build jitter value in Track Stats", () => {
|
||||
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(0);
|
||||
});
|
||||
|
||||
it("and returns track stats with jitter", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(10);
|
||||
});
|
||||
|
||||
it("and returns negative jitter if stats has no jitter value", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
|
||||
expect(trackStats.getJitter()).toEqual(-1);
|
||||
});
|
||||
|
||||
it("and returns jitter as number", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
|
||||
expect(trackStats.getJitter()).toEqual(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildMockTrackStatsList(): MediaTrackStats[] {
|
||||
const trackStats1 = new MediaTrackStats("1", "local", "video");
|
||||
trackStats1.muted = false;
|
||||
trackStats1.alive = true;
|
||||
const trackStats2 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats2.muted = false;
|
||||
trackStats2.alive = true;
|
||||
const trackStats3 = new MediaTrackStats("1", "remote", "audio");
|
||||
trackStats3.muted = false;
|
||||
trackStats3.alive = true;
|
||||
const trackStats4 = new MediaTrackStats("1", "local", "audio");
|
||||
trackStats4.muted = false;
|
||||
trackStats4.alive = true;
|
||||
const trackStats5 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats5.muted = false;
|
||||
trackStats5.alive = true;
|
||||
const trackStats6 = new MediaTrackStats("1", "remote", "audio");
|
||||
trackStats6.muted = false;
|
||||
trackStats6.alive = true;
|
||||
const trackStats7 = new MediaTrackStats("1", "remote", "video");
|
||||
trackStats7.muted = false;
|
||||
trackStats7.alive = true;
|
||||
return [trackStats1, trackStats2, trackStats3, trackStats4, trackStats5, trackStats6, trackStats7];
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ export interface LoginTokenPostResponse {
|
||||
login_token: string;
|
||||
/**
|
||||
* Expiration in seconds.
|
||||
*
|
||||
* @deprecated this is only provided for compatibility with original revision of the MSC.
|
||||
*/
|
||||
expires_in: number;
|
||||
/**
|
||||
* Expiration in milliseconds.
|
||||
*/
|
||||
expires_in_ms: number;
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export interface IRelationsRequestOpts {
|
||||
to?: string;
|
||||
limit?: number;
|
||||
dir?: Direction;
|
||||
recurse?: boolean; // MSC3981 Relations Recursion https://github.com/matrix-org/matrix-spec-proposals/pull/3981
|
||||
}
|
||||
|
||||
export interface IRelationsResponse {
|
||||
|
||||
+2
-2
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IAuthData } from "../interactive-auth";
|
||||
import { IAuthDict, IAuthData } from "../interactive-auth";
|
||||
|
||||
/**
|
||||
* Helper type to represent HTTP request body for a UIA enabled endpoint
|
||||
*/
|
||||
export type UIARequest<T> = T & {
|
||||
auth?: IAuthData;
|
||||
auth?: IAuthDict;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -25,7 +25,7 @@ export class ReEmitter {
|
||||
public constructor(private readonly target: EventEmitter) {}
|
||||
|
||||
// Map from emitter to event name to re-emitter
|
||||
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
|
||||
private reEmitters = new WeakMap<EventEmitter, Map<string, (...args: any[]) => void>>();
|
||||
|
||||
public reEmit(source: EventEmitter, eventNames: string[]): void {
|
||||
let reEmittersByEvent = this.reEmitters.get(source);
|
||||
@@ -35,6 +35,8 @@ export class ReEmitter {
|
||||
}
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
if (reEmittersByEvent.has(eventName)) continue;
|
||||
|
||||
// We include the source as the last argument for event handlers which may need it,
|
||||
// such as read receipt listeners on the client class which won't have the context
|
||||
// of the room.
|
||||
|
||||
+187
-98
@@ -30,6 +30,7 @@ import {
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MatrixEventHandlerMap,
|
||||
PushDetails,
|
||||
} from "./models/event";
|
||||
import { StubStore } from "./store/stub";
|
||||
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
|
||||
@@ -37,7 +38,7 @@ import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
|
||||
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
|
||||
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
|
||||
import * as utils from "./utils";
|
||||
import { replaceParam, QueryDict, sleep, noUnsafeEventProps } from "./utils";
|
||||
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
import { IActionsObject, PushProcessor } from "./pushprocessor";
|
||||
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
CryptoEventHandlerMap,
|
||||
fixBackupKey,
|
||||
ICryptoCallbacks,
|
||||
IBootstrapCrossSigningOpts,
|
||||
ICheckOwnCrossSigningTrustOpts,
|
||||
isCryptoAvailable,
|
||||
VerificationMethod,
|
||||
@@ -101,7 +101,6 @@ import { IAuthData, IAuthDict } from "./interactive-auth";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
IAddSecretStorageKeyOpts,
|
||||
ICreateSecretStorageOpts,
|
||||
IEncryptedEventInfo,
|
||||
IImportRoomKeysOpts,
|
||||
@@ -205,9 +204,14 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
import { CryptoApi } from "./crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
|
||||
import { DeviceInfoMap } from "./crypto/DeviceList";
|
||||
import { SecretStorageKeyDescription } from "./secret-storage";
|
||||
import {
|
||||
AddSecretStorageKeyOpts,
|
||||
SecretStorageKeyDescription,
|
||||
ServerSideSecretStorage,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "./secret-storage";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -489,11 +493,21 @@ export interface IChangePasswordCapability extends ICapability {}
|
||||
|
||||
export interface IThreadsCapability extends ICapability {}
|
||||
|
||||
interface ICapabilities {
|
||||
export interface IMSC3882GetLoginTokenCapability extends ICapability {}
|
||||
|
||||
export const UNSTABLE_MSC3882_CAPABILITY = new UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token");
|
||||
|
||||
/**
|
||||
* A representation of the capabilities advertised by a homeserver as defined by
|
||||
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
|
||||
*/
|
||||
export interface Capabilities {
|
||||
[key: string]: any;
|
||||
"m.change_password"?: IChangePasswordCapability;
|
||||
"m.room_versions"?: IRoomVersionsCapability;
|
||||
"io.element.thread"?: IThreadsCapability;
|
||||
[UNSTABLE_MSC3882_CAPABILITY.name]?: IMSC3882GetLoginTokenCapability;
|
||||
[UNSTABLE_MSC3882_CAPABILITY.altName]?: IMSC3882GetLoginTokenCapability;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
@@ -620,7 +634,7 @@ interface IJoinRequestBody {
|
||||
|
||||
interface ITagMetadata {
|
||||
[key: string]: any;
|
||||
order: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface IMessagesResponse {
|
||||
@@ -700,7 +714,7 @@ interface IJoinedMembersResponse {
|
||||
}
|
||||
|
||||
export interface IRegisterRequestParams {
|
||||
auth?: IAuthData;
|
||||
auth?: IAuthDict;
|
||||
username?: string;
|
||||
password?: string;
|
||||
refresh_token?: boolean;
|
||||
@@ -1226,7 +1240,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected serverVersionsPromise?: Promise<IServerVersions>;
|
||||
|
||||
public cachedCapabilities?: {
|
||||
capabilities: ICapabilities;
|
||||
capabilities: Capabilities;
|
||||
expiration: number;
|
||||
};
|
||||
protected clientWellKnown?: IClientWellKnown;
|
||||
@@ -1243,6 +1257,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
private useE2eForGroupCall = true;
|
||||
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
||||
|
||||
private _secretStorage: ServerSideSecretStorageImpl;
|
||||
|
||||
// A manager for determining which invites should be ignored.
|
||||
public readonly ignoredInvites: IgnoredInvites;
|
||||
|
||||
@@ -1408,6 +1424,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
|
||||
this.ignoredInvites = new IgnoredInvites(this);
|
||||
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2045,7 +2062,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves to the capabilities of the homeserver
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public getCapabilities(fresh = false): Promise<ICapabilities> {
|
||||
public getCapabilities(fresh = false): Promise<Capabilities> {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (this.cachedCapabilities && !fresh) {
|
||||
@@ -2056,7 +2073,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
type Response = {
|
||||
capabilities?: ICapabilities;
|
||||
capabilities?: Capabilities;
|
||||
};
|
||||
return this.http
|
||||
.authedRequest<Response>(Method.Get, "/capabilities")
|
||||
@@ -2210,13 +2227,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
|
||||
// needed.
|
||||
const RustCrypto = await import("./rust-crypto");
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
|
||||
this.cryptoBackend = rustCrypto;
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the server-side secret storage API for this client.
|
||||
*/
|
||||
public get secretStorage(): ServerSideSecretStorage {
|
||||
return this._secretStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the crypto API for this client.
|
||||
*
|
||||
@@ -2269,7 +2293,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param userIds - The users to fetch.
|
||||
* @param forceDownload - Always download the keys even if cached.
|
||||
*
|
||||
* @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}
|
||||
* @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo`
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
|
||||
if (!this.crypto) {
|
||||
@@ -2284,6 +2310,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param userId - the user to list keys for.
|
||||
*
|
||||
* @returns list of devices
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public getStoredDevicesForUser(userId: string): DeviceInfo[] {
|
||||
if (!this.crypto) {
|
||||
@@ -2299,6 +2326,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param deviceId - unique identifier for the device
|
||||
*
|
||||
* @returns device or null
|
||||
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
|
||||
*/
|
||||
public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
|
||||
if (!this.crypto) {
|
||||
@@ -2409,10 +2437,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.findVerificationRequestDMInProgress(roomId);
|
||||
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2463,11 +2491,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.crypto.beginKeyVerification(method, userId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
|
||||
*/
|
||||
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.checkSecretStorageKey(key, info);
|
||||
return this.secretStorage.checkKey(key, info);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2543,14 +2571,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's cross-signing key ID.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
* Get the ID of one of the user's cross-signing keys
|
||||
*
|
||||
* @param type - The type of key to get the ID of. One of
|
||||
* "master", "self_signing", or "user_signing". Defaults to "master".
|
||||
*
|
||||
* @returns the key ID
|
||||
* @deprecated prefer {@link CryptoApi#getCrossSigningKeyId}
|
||||
*/
|
||||
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
|
||||
if (!this.crypto) {
|
||||
@@ -2569,10 +2596,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getStoredCrossSigningForUser(userId);
|
||||
return this.cryptoBackend.getStoredCrossSigningForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2596,12 +2623,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
if (!this.cryptoBackend) {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
|
||||
return this.crypto.checkDeviceTrust(userId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2705,12 +2734,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* bootstrapCrossSigning() completes successfully, this function should
|
||||
* return true.
|
||||
* @returns True if cross-signing is ready to be used on this device
|
||||
* @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}:
|
||||
*/
|
||||
public isCrossSigningReady(): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.isCrossSigningReady();
|
||||
return this.cryptoBackend.isCrossSigningReady();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2720,15 +2750,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* This function:
|
||||
* - creates new cross-signing keys if they are not found locally cached nor in
|
||||
* secret storage (if it has been setup)
|
||||
* secret storage (if it has been set up)
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
* @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}.
|
||||
*/
|
||||
public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
public bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.bootstrapCrossSigning(opts);
|
||||
return this.cryptoBackend.bootstrapCrossSigning(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2739,24 +2769,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Default: true
|
||||
*
|
||||
* @returns True if trusting cross-signed devices
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}.
|
||||
*/
|
||||
public getCryptoTrustCrossSignedDevices(): boolean {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getCryptoTrustCrossSignedDevices();
|
||||
return this.cryptoBackend.getTrustCrossSignedDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* See getCryptoTrustCrossSignedDevices
|
||||
*
|
||||
* @param val - True to trust cross-signed devices
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}.
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
this.crypto.setCryptoTrustCrossSignedDevices(val);
|
||||
this.cryptoBackend.setTrustCrossSignedDevices(val);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2813,15 +2847,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* bootstrapSecretStorage() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
|
||||
*/
|
||||
public isSecretStorageReady(): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.isSecretStorageReady();
|
||||
return this.cryptoBackend.isSecretStorageReady();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2858,16 +2891,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns An object with:
|
||||
* keyId: the ID of the key
|
||||
* keyInfo: details about the key (iv, mac, passphrase)
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
|
||||
*/
|
||||
public addSecretStorageKey(
|
||||
algorithm: string,
|
||||
opts: IAddSecretStorageKeyOpts,
|
||||
opts: AddSecretStorageKeyOpts,
|
||||
keyName?: string,
|
||||
): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.addSecretStorageKey(algorithm, opts, keyName);
|
||||
return this.secretStorage.addKey(algorithm, opts, keyName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2878,12 +2910,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns Whether we have the key.
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
|
||||
*/
|
||||
public hasSecretStorageKey(keyId?: string): Promise<boolean> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.hasSecretStorageKey(keyId);
|
||||
return this.secretStorage.hasKey(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2895,12 +2926,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param secret - The secret contents.
|
||||
* @param keys - The IDs of the keys to use to encrypt the secret or null/undefined
|
||||
* to use the default (will throw if no default key is set).
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
|
||||
*/
|
||||
public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.storeSecret(name, secret, keys);
|
||||
return this.secretStorage.store(name, secret, keys);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2911,12 +2941,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param name - the name of the secret
|
||||
*
|
||||
* @returns the contents of the secret
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
|
||||
*/
|
||||
public getSecret(name: string): Promise<string | undefined> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getSecret(name);
|
||||
return this.secretStorage.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2928,12 +2957,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns map of key name to key info the secret is encrypted
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* key
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
|
||||
*/
|
||||
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.isSecretStored(name);
|
||||
return this.secretStorage.isStored(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2959,12 +2987,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @returns The default key ID or null if no default key ID is set
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
|
||||
*/
|
||||
public getDefaultSecretStorageKeyId(): Promise<string | null> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getDefaultSecretStorageKeyId();
|
||||
return this.secretStorage.getDefaultKeyId();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2973,12 +3000,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @param keyId - The new default key ID
|
||||
*
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
|
||||
*/
|
||||
public setDefaultSecretStorageKeyId(keyId: string): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.setDefaultSecretStorageKeyId(keyId);
|
||||
return this.secretStorage.setDefaultKeyId(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2991,6 +3017,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param privateKey - The private key
|
||||
* @param expectedPublicKey - The public key
|
||||
* @returns true if the key matches, otherwise false
|
||||
*
|
||||
* @deprecated The use of asymmetric keys for SSSS is deprecated.
|
||||
* Use {@link SecretStorage.ServerSideSecretStorage#checkKey} for symmetric keys.
|
||||
*/
|
||||
public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
|
||||
if (!this.crypto) {
|
||||
@@ -3287,7 +3316,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
await this.crypto.backupManager.prepareKeyBackupVersion(password);
|
||||
|
||||
if (opts.secureSecretStorage) {
|
||||
await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
logger.info("Key backup private key stored in secret storage");
|
||||
}
|
||||
|
||||
@@ -3307,7 +3336,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* trusted key
|
||||
*/
|
||||
public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> {
|
||||
return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
|
||||
return Promise.resolve(this.secretStorage.isStored("m.megolm_backup.v1"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3572,14 +3601,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
const storedKey = await this.getSecret("m.megolm_backup.v1");
|
||||
const storedKey = await this.secretStorage.get("m.megolm_backup.v1");
|
||||
|
||||
// ensure that the key is in the right format. If not, fix the key and
|
||||
// store the fixed version
|
||||
const fixedKey = fixBackupKey(storedKey);
|
||||
if (fixedKey) {
|
||||
const keys = await this.crypto.getSecretStorageKey();
|
||||
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
const keys = await this.secretStorage.getKey();
|
||||
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
}
|
||||
|
||||
const privKey = decodeBase64(fixedKey || storedKey!);
|
||||
@@ -4158,7 +4187,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
|
||||
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata = {}): Promise<{}> {
|
||||
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
|
||||
$userId: this.credentials.userId!,
|
||||
$roomId: roomId,
|
||||
@@ -5365,11 +5394,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null {
|
||||
if (!event.getPushActions() || forceRecalculate) {
|
||||
event.setPushActions(this.pushProcessor.actionsForEvent(event));
|
||||
const { actions, rule } = this.pushProcessor.actionsAndRuleForEvent(event);
|
||||
event.setPushDetails(actions, rule);
|
||||
}
|
||||
return event.getPushActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a dict of actions which should be performed for this event according
|
||||
* to the push rules for this user. Caches the dict on the event.
|
||||
* @param event - The event to get push actions for.
|
||||
* @param forceRecalculate - forces to recalculate actions for an event
|
||||
* Useful when an event just got decrypted
|
||||
* @returns A dict of actions to perform.
|
||||
*/
|
||||
public getPushDetailsForEvent(event: MatrixEvent, forceRecalculate = false): PushDetails | null {
|
||||
if (!event.getPushDetails() || forceRecalculate) {
|
||||
const { actions, rule } = this.pushProcessor.actionsAndRuleForEvent(event);
|
||||
event.setPushDetails(actions, rule);
|
||||
}
|
||||
return event.getPushDetails();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param info - The kind of info to set (e.g. 'avatar_url')
|
||||
* @param data - The JSON object to set.
|
||||
@@ -5692,6 +5738,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
if (Thread.hasServerSideSupport) {
|
||||
if (Thread.hasServerSideFwdPaginationSupport) {
|
||||
if (!timelineSet.thread) {
|
||||
@@ -5704,14 +5751,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, from: res.start },
|
||||
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
|
||||
);
|
||||
const resNewer: IRelationsResponse = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Forward, from: res.end },
|
||||
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
|
||||
);
|
||||
const events = [
|
||||
// Order events from most recent to oldest (reverse-chronological).
|
||||
@@ -5759,7 +5806,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, from: res.start },
|
||||
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
|
||||
);
|
||||
const eventsNewer: IEvent[] = [];
|
||||
let nextBatch: Optional<string> = res.end;
|
||||
@@ -5769,7 +5816,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Forward, from: nextBatch },
|
||||
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
|
||||
);
|
||||
nextBatch = resNewer.next_batch ?? null;
|
||||
eventsNewer.push(...resNewer.chunk);
|
||||
@@ -5840,12 +5887,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
event = res.chunk?.[0];
|
||||
} else if (timelineSet.thread && Thread.hasServerSideSupport) {
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
const res = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
timelineSet.thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
{ dir: Direction.Backward, limit: 1 },
|
||||
{ dir: Direction.Backward, limit: 1, recurse: recurse || undefined },
|
||||
);
|
||||
event = res.chunk?.[0];
|
||||
} else {
|
||||
@@ -6044,7 +6092,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
for (let i = 0; i < res.notifications.length; i++) {
|
||||
const notification = res.notifications[i];
|
||||
const event = this.getEventMapper()(notification.event);
|
||||
event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions));
|
||||
|
||||
// @TODO(kerrya) reprocessing every notification is ugly
|
||||
// remove if we get server MSC3994 support
|
||||
this.getPushDetailsForEvent(event, true);
|
||||
|
||||
event.event.room_id = notification.room_id; // XXX: gutwrenching
|
||||
matrixEvents[i] = event;
|
||||
}
|
||||
@@ -6116,10 +6168,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("Unknown room " + eventTimeline.getRoomId());
|
||||
}
|
||||
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
|
||||
dir,
|
||||
limit: opts.limit,
|
||||
from: token ?? undefined,
|
||||
recurse: recurse || undefined,
|
||||
})
|
||||
.then(async (res) => {
|
||||
const mapper = this.getEventMapper();
|
||||
@@ -7462,7 +7516,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Set the identity server URL of this client
|
||||
* @param url - New identity server URL
|
||||
*/
|
||||
public setIdentityServerUrl(url: string): void {
|
||||
public setIdentityServerUrl(url?: string): void {
|
||||
this.idBaseUrl = utils.ensureNoTrailingSlash(url);
|
||||
this.http.setIdBaseUrl(this.idBaseUrl);
|
||||
}
|
||||
@@ -7809,15 +7863,33 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: On success, the token response
|
||||
* or UIA auth data.
|
||||
*/
|
||||
public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
public async requestLoginToken(auth?: IAuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
// use capabilities to determine which revision of the MSC is being used
|
||||
const capabilities = await this.getCapabilities();
|
||||
// use r1 endpoint if capability is exposed otherwise use old r0 endpoint
|
||||
const endpoint = UNSTABLE_MSC3882_CAPABILITY.findIn(capabilities)
|
||||
? "/org.matrix.msc3882/login/get_token" // r1 endpoint
|
||||
: "/org.matrix.msc3882/login/token"; // r0 endpoint
|
||||
|
||||
const body: UIARequest<{}> = { auth };
|
||||
return this.http.authedRequest(
|
||||
const res = await this.http.authedRequest<UIAResponse<LoginTokenPostResponse>>(
|
||||
Method.Post,
|
||||
"/org.matrix.msc3882/login/token",
|
||||
endpoint,
|
||||
undefined, // no query params
|
||||
body,
|
||||
{ prefix: ClientPrefix.Unstable },
|
||||
);
|
||||
|
||||
// the representation of expires_in changed from revision 0 to revision 1 so we populate
|
||||
if ("login_token" in res) {
|
||||
if (typeof res.expires_in_ms === "number") {
|
||||
res.expires_in = Math.floor(res.expires_in_ms / 1000);
|
||||
} else if (typeof res.expires_in === "number") {
|
||||
res.expires_in_ms = res.expires_in * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7890,6 +7962,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
|
||||
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
|
||||
}
|
||||
if (this.canSupport.get(Feature.RelationsRecursion) === ServerSupport.Unstable) {
|
||||
params = replaceParam("recurse", "org.matrix.msc3981.recurse", params);
|
||||
}
|
||||
const queryString = utils.encodeParams(params);
|
||||
|
||||
let templatedUrl = "/rooms/$roomId/relations/$eventId";
|
||||
@@ -8170,7 +8245,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
|
||||
// eslint-disable-line camelcase
|
||||
// TODO: deprecate this or resolveRoomAlias
|
||||
const path = utils.encodeUri("/directory/room/$alias", {
|
||||
$alias: alias,
|
||||
});
|
||||
@@ -8180,10 +8254,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* @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[] }> {
|
||||
// TODO: deprecate this or getRoomIdForAlias
|
||||
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
|
||||
return this.http.request(Method.Get, path);
|
||||
}
|
||||
@@ -8555,6 +8629,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Post, path, undefined, pusher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an existing pusher
|
||||
* @param pushKey - pushkey of pusher to remove
|
||||
* @param appId - app_id of pusher to remove
|
||||
* @returns Promise which resolves: Empty json object on success
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public removePusher(pushKey: string, appId: string): Promise<{}> {
|
||||
const path = "/pushers/set";
|
||||
const body = {
|
||||
pushkey: pushKey,
|
||||
app_id: appId,
|
||||
kind: null, // marks pusher for removal
|
||||
};
|
||||
return this.http.authedRequest(Method.Post, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists local notification settings
|
||||
* @returns Promise which resolves: an empty object
|
||||
@@ -8749,8 +8840,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
for (const [userId, deviceId] of devices) {
|
||||
const query = queries[userId] || {};
|
||||
queries[userId] = query;
|
||||
query[deviceId] = keyAlgorithm;
|
||||
safeSet(queries, userId, query);
|
||||
safeSet(query, deviceId, keyAlgorithm);
|
||||
}
|
||||
const content: IClaimKeysRequest = { one_time_keys: queries };
|
||||
if (timeout) {
|
||||
@@ -8777,7 +8868,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Get, "/keys/changes", qps);
|
||||
}
|
||||
|
||||
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
|
||||
public uploadDeviceSigningKeys(auth?: IAuthDict, keys?: CrossSigningKeys): Promise<{}> {
|
||||
// API returns empty object
|
||||
const data = Object.assign({}, keys);
|
||||
if (auth) Object.assign(data, { auth });
|
||||
@@ -8926,7 +9017,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param identityAccessToken - The `access_token` field of the Identity
|
||||
* Server `/account/register` response (see {@link registerWithIdentityServer}).
|
||||
*
|
||||
* @returns Promise which resolves: Object, currently with no parameters.
|
||||
* @returns Promise which resolves: Object, containing success boolean.
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws Error if No identity server is set
|
||||
*/
|
||||
@@ -8935,8 +9026,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
identityAccessToken: string,
|
||||
): Promise<any> {
|
||||
// TODO: Types
|
||||
): Promise<{ success: boolean }> {
|
||||
const params = {
|
||||
sid: sid,
|
||||
client_secret: clientSecret,
|
||||
@@ -8967,7 +9057,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* This must be the same value submitted in the requestToken call.
|
||||
* @param msisdnToken - The MSISDN token, as enetered by the user.
|
||||
*
|
||||
* @returns Promise which resolves: Object, currently with no parameters.
|
||||
* @returns Promise which resolves: Object, containing success boolean.
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public submitMsisdnTokenOtherUrl(
|
||||
@@ -8975,8 +9065,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
sid: string,
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
): Promise<any> {
|
||||
// TODO: Types
|
||||
): Promise<{ success: boolean }> {
|
||||
const params = {
|
||||
sid: sid,
|
||||
client_secret: clientSecret,
|
||||
|
||||
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IToDeviceEvent } from "../sync-accumulator";
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
@@ -51,16 +52,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
*/
|
||||
checkUserTrust(userId: string): UserTrustLevel;
|
||||
|
||||
/**
|
||||
* Get the verification level for a given device
|
||||
*
|
||||
* TODO: define this better
|
||||
*
|
||||
* @param userId - user to be checked
|
||||
* @param deviceId - device to be checked
|
||||
*/
|
||||
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
|
||||
|
||||
/**
|
||||
* Encrypt an event according to the configuration of the room.
|
||||
*
|
||||
@@ -87,6 +78,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @param event - event to be checked
|
||||
*/
|
||||
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @param userId - the user ID to get the cross-signing info for.
|
||||
*
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api */
|
||||
@@ -106,32 +117,20 @@ export interface SyncCryptoCallbacks {
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop whenever there are incoming to-device messages.
|
||||
*
|
||||
* The implementation may preprocess the received messages (eg, decrypt them) and return an
|
||||
* updated list of messages for dispatch to the rest of the system.
|
||||
*
|
||||
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
* Called by the /sync loop when one time key counts and unused fallback key details are received.
|
||||
*
|
||||
* @param oneTimeKeysCounts - the received one time key counts
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
*/
|
||||
preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void>;
|
||||
processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop whenever there are incoming to-device messages.
|
||||
* Handle the notification from /sync that device lists have
|
||||
* been changed.
|
||||
*
|
||||
* The implementation may preprocess the received messages (eg, decrypt them) and return an
|
||||
* updated list of messages for dispatch to the rest of the system.
|
||||
*
|
||||
* Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
*
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @param deviceLists - device_lists field from /sync
|
||||
*/
|
||||
preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void>;
|
||||
processDeviceLists(deviceLists: IDeviceLists): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop whenever an m.room.encryption event is received.
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as utils from "./utils";
|
||||
import { encodeParams } from "./utils";
|
||||
|
||||
/**
|
||||
* Get the HTTP URL for an MXC URI.
|
||||
@@ -74,6 +74,6 @@ export function getHttpUriForMxc(
|
||||
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
|
||||
}
|
||||
|
||||
const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
|
||||
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
|
||||
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ limitations under the License.
|
||||
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
import { UIAuthCallback } from "./interactive-auth";
|
||||
|
||||
/** Types of cross-signing key */
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -72,4 +81,182 @@ export interface CryptoApi {
|
||||
* session export objects
|
||||
*/
|
||||
exportRoomKeys(): Promise<IMegolmSessionData[]>;
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
|
||||
* cached device data is returned.
|
||||
*
|
||||
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
|
||||
* a `/keys/query` request is made to the server to retrieve these devices.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
getUserDeviceInfo(userIds: string[], downloadUncached?: boolean): Promise<DeviceMap>;
|
||||
|
||||
/**
|
||||
* Set whether to trust other user's signatures of their devices.
|
||||
*
|
||||
* If false, devices will only be considered 'verified' if we have
|
||||
* verified that device individually (effectively disabling cross-signing).
|
||||
*
|
||||
* `true` by default.
|
||||
*
|
||||
* @param val - the new value
|
||||
*/
|
||||
setTrustCrossSignedDevices(val: boolean): void;
|
||||
|
||||
/**
|
||||
* Return whether we trust other user's signatures of their devices.
|
||||
*
|
||||
* @see {@link CryptoApi#setTrustCrossSignedDevices}
|
||||
*
|
||||
* @returns `true` if we trust cross-signed devices, otherwise `false`.
|
||||
*/
|
||||
getTrustCrossSignedDevices(): boolean;
|
||||
|
||||
/**
|
||||
* Get the verification status of a given device.
|
||||
*
|
||||
* @param userId - The ID of the user whose device is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @returns Verification status of the device, or `null` if the device is not known
|
||||
*/
|
||||
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
|
||||
|
||||
/**
|
||||
* Checks whether cross signing:
|
||||
* - is enabled on this account and trusted by this device
|
||||
* - has private keys either cached locally or stored in secret storage
|
||||
*
|
||||
* If this function returns false, bootstrapCrossSigning() can be used
|
||||
* to fix things such that it returns true. That is to say, after
|
||||
* bootstrapCrossSigning() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* @returns True if cross-signing is ready to be used on this device
|
||||
*/
|
||||
isCrossSigningReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the ID of one of the user's cross-signing keys.
|
||||
*
|
||||
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigngingKey.SelfSigning`,
|
||||
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
|
||||
*
|
||||
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
|
||||
*/
|
||||
getCrossSigningKeyId(type?: CrossSigningKey): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Bootstrap cross-signing by creating keys if needed.
|
||||
*
|
||||
* If everything is already set up, then no changes are made, so this is safe to run to ensure
|
||||
* cross-signing is ready for use.
|
||||
*
|
||||
* This function:
|
||||
* - creates new cross-signing keys if they are not found locally cached nor in
|
||||
* secret storage (if it has been set up)
|
||||
* - publishes the public keys to the server if they are not already published
|
||||
* - stores the private keys in secret storage if secret storage is set up.
|
||||
*
|
||||
* @param opts - options object
|
||||
*/
|
||||
bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Checks whether secret storage:
|
||||
* - is enabled on this account
|
||||
* - is storing cross-signing private keys
|
||||
* - is storing session backup key (if enabled)
|
||||
*
|
||||
* If this function returns false, bootstrapSecretStorage() can be used
|
||||
* to fix things such that it returns true. That is to say, after
|
||||
* bootstrapSecretStorage() completes successfully, this function should
|
||||
* return true.
|
||||
*
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for `CryptoApi.bootstrapCrossSigning`.
|
||||
*/
|
||||
export interface BootstrapCrossSigningOpts {
|
||||
/** Optional. Reset the cross-signing keys even if keys already exist. */
|
||||
setupNewCrossSigning?: boolean;
|
||||
|
||||
/**
|
||||
* An application callback to collect the authentication data for uploading the keys. If not given, the keys
|
||||
* will not be uploaded to the server (which seems like a bad thing?).
|
||||
*/
|
||||
authUploadDeviceSigningKeys?: UIAuthCallback<void>;
|
||||
}
|
||||
|
||||
export class DeviceVerificationStatus {
|
||||
/**
|
||||
* True if this device has been signed by its owner (and that signature verified).
|
||||
*
|
||||
* This doesn't necessarily mean that we have verified the device, since we may not have verified the
|
||||
* owner's cross-signing key.
|
||||
*/
|
||||
public readonly signedByOwner: boolean;
|
||||
|
||||
/**
|
||||
* True if this device has been verified via cross signing.
|
||||
*
|
||||
* This does *not* take into account `trustCrossSignedDevices`.
|
||||
*/
|
||||
public readonly crossSigningVerified: boolean;
|
||||
|
||||
/**
|
||||
* TODO: tofu magic wtf does this do?
|
||||
*/
|
||||
public readonly tofu: boolean;
|
||||
|
||||
/**
|
||||
* True if the device has been marked as locally verified.
|
||||
*/
|
||||
public readonly localVerified: boolean;
|
||||
|
||||
/**
|
||||
* True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
private readonly trustCrossSignedDevices: boolean;
|
||||
|
||||
public constructor(
|
||||
opts: Partial<DeviceVerificationStatus> & {
|
||||
/**
|
||||
* True if cross-signed devices should be considered verified for {@link DeviceVerificationStatus#isVerified}.
|
||||
*/
|
||||
trustCrossSignedDevices?: boolean;
|
||||
},
|
||||
) {
|
||||
this.signedByOwner = opts.signedByOwner ?? false;
|
||||
this.crossSigningVerified = opts.crossSigningVerified ?? false;
|
||||
this.tofu = opts.tofu ?? false;
|
||||
this.localVerified = opts.localVerified ?? false;
|
||||
this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should consider this device "verified".
|
||||
*
|
||||
* A device is "verified" if either:
|
||||
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
|
||||
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
|
||||
* cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*
|
||||
* @returns true if this device is verified via any means.
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -25,13 +25,13 @@ import { logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { SecretStorage } from "./SecretStorage";
|
||||
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import { ICryptoCallbacks } from ".";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { DeviceVerificationStatus } from "../crypto-api";
|
||||
|
||||
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
|
||||
|
||||
@@ -164,7 +164,7 @@ export class CrossSigningInfo {
|
||||
* key
|
||||
*/
|
||||
public async isStoredInSecretStorage(
|
||||
secretStorage: SecretStorage<MatrixClient | undefined>,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
): Promise<Record<string, object> | null> {
|
||||
// check what SSSS keys have encrypted the master key (if any)
|
||||
const stored = (await secretStorage.isStored("m.cross_signing.master")) || {};
|
||||
@@ -192,7 +192,7 @@ export class CrossSigningInfo {
|
||||
*/
|
||||
public static async storeInSecretStorage(
|
||||
keys: Map<string, Uint8Array>,
|
||||
secretStorage: SecretStorage<undefined>,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
): Promise<void> {
|
||||
for (const [type, privateKey] of keys) {
|
||||
const encodedKey = encodeBase64(privateKey);
|
||||
@@ -209,7 +209,10 @@ export class CrossSigningInfo {
|
||||
* @param secretStorage - The secret store using account data
|
||||
* @returns The private key
|
||||
*/
|
||||
public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> {
|
||||
public static async getFromSecretStorage(
|
||||
type: string,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
): Promise<Uint8Array | null> {
|
||||
const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
|
||||
if (!encodedKey) {
|
||||
return null;
|
||||
@@ -626,15 +629,20 @@ export class UserTrustLevel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the ways in which we trust a device
|
||||
* Represents the ways in which we trust a device.
|
||||
*
|
||||
* @deprecated Use {@link DeviceVerificationStatus}.
|
||||
*/
|
||||
export class DeviceTrustLevel {
|
||||
export class DeviceTrustLevel extends DeviceVerificationStatus {
|
||||
public constructor(
|
||||
public readonly crossSigningVerified: boolean,
|
||||
public readonly tofu: boolean,
|
||||
private readonly localVerified: boolean,
|
||||
private readonly trustCrossSignedDevices: boolean,
|
||||
) {}
|
||||
crossSigningVerified: boolean,
|
||||
tofu: boolean,
|
||||
localVerified: boolean,
|
||||
trustCrossSignedDevices: boolean,
|
||||
signedByOwner = false,
|
||||
) {
|
||||
super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner });
|
||||
}
|
||||
|
||||
public static fromUserTrustLevel(
|
||||
userTrustLevel: UserTrustLevel,
|
||||
@@ -646,16 +654,10 @@ export class DeviceTrustLevel {
|
||||
userTrustLevel.isTofu(),
|
||||
localVerified,
|
||||
trustCrossSignedDevices,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this device is verified via any means
|
||||
*/
|
||||
public isVerified(): boolean {
|
||||
return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if this device is verified via cross signing
|
||||
*/
|
||||
|
||||
@@ -15,11 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { Method, ClientPrefix } from "../http-api";
|
||||
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
|
||||
import { Crypto, ICryptoCallbacks } from "./index";
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
@@ -30,11 +30,11 @@ import {
|
||||
} from "../client";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IAccountDataClient } from "./SecretStorage";
|
||||
import { SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
|
||||
interface ICrossSigningKeys {
|
||||
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
|
||||
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
|
||||
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export class EncryptionSetupOperation {
|
||||
*/
|
||||
class AccountDataClientAdapter
|
||||
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
|
||||
implements IAccountDataClient
|
||||
implements AccountDataClient
|
||||
{
|
||||
//
|
||||
public readonly values = new Map<string, MatrixEvent>();
|
||||
@@ -253,21 +253,21 @@ class AccountDataClientAdapter
|
||||
/**
|
||||
* @returns the content of the account data
|
||||
*/
|
||||
public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T> {
|
||||
return Promise.resolve(this.getAccountData(type) as T);
|
||||
public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T | null> {
|
||||
return Promise.resolve(this.getAccountData(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the content of the account data
|
||||
*/
|
||||
public getAccountData(type: string): IContent | null {
|
||||
public getAccountData<T extends { [k: string]: any }>(type: string): T | null {
|
||||
const modifiedValue = this.values.get(type);
|
||||
if (modifiedValue) {
|
||||
return modifiedValue;
|
||||
return modifiedValue as unknown as T;
|
||||
}
|
||||
const existingValue = this.existingValues.get(type);
|
||||
if (existingValue) {
|
||||
return existingValue.getContent();
|
||||
return existingValue.getContent<T>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { ICryptoCallbacks, IEncryptedContent } from "./index";
|
||||
import { defer, IDeferred } from "../utils";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
import { logger } from "../logger";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import * as olmlib from "./olmlib";
|
||||
|
||||
export interface ISecretRequest {
|
||||
requestId: string;
|
||||
promise: Promise<string>;
|
||||
cancel: (reason: string) => void;
|
||||
}
|
||||
|
||||
interface ISecretRequestInternal {
|
||||
name: string;
|
||||
devices: string[];
|
||||
deferred: IDeferred<string>;
|
||||
}
|
||||
|
||||
export class SecretSharing {
|
||||
private requests = new Map<string, ISecretRequestInternal>();
|
||||
|
||||
public constructor(private readonly baseApis: MatrixClient, private readonly cryptoCallbacks: ICryptoCallbacks) {}
|
||||
|
||||
/**
|
||||
* Request a secret from another device
|
||||
*
|
||||
* @param name - the name of the secret to request
|
||||
* @param devices - the devices to request the secret from
|
||||
*/
|
||||
public request(name: string, devices: string[]): ISecretRequest {
|
||||
const requestId = this.baseApis.makeTxnId();
|
||||
|
||||
const deferred = defer<string>();
|
||||
this.requests.set(requestId, { name, devices, deferred });
|
||||
|
||||
const cancel = (reason: string): void => {
|
||||
// send cancellation event
|
||||
const cancelData = {
|
||||
action: "request_cancellation",
|
||||
requesting_device_id: this.baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
};
|
||||
const toDevice: Map<string, typeof cancelData> = new Map();
|
||||
for (const device of devices) {
|
||||
toDevice.set(device, cancelData);
|
||||
}
|
||||
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
|
||||
|
||||
// and reject the promise so that anyone waiting on it will be
|
||||
// notified
|
||||
deferred.reject(new Error(reason || "Cancelled"));
|
||||
};
|
||||
|
||||
// send request to devices
|
||||
const requestData = {
|
||||
name,
|
||||
action: "request",
|
||||
requesting_device_id: this.baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
[ToDeviceMessageId]: uuidv4(),
|
||||
};
|
||||
const toDevice: Map<string, typeof requestData> = new Map();
|
||||
for (const device of devices) {
|
||||
toDevice.set(device, requestData);
|
||||
}
|
||||
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
|
||||
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
|
||||
|
||||
return {
|
||||
requestId,
|
||||
promise: deferred.promise,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
public async onRequestReceived(event: MatrixEvent): Promise<void> {
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent();
|
||||
if (
|
||||
sender !== this.baseApis.getUserId() ||
|
||||
!(content.name && content.action && content.requesting_device_id && content.request_id)
|
||||
) {
|
||||
// ignore requests from anyone else, for now
|
||||
return;
|
||||
}
|
||||
const deviceId = content.requesting_device_id;
|
||||
// check if it's a cancel
|
||||
if (content.action === "request_cancellation") {
|
||||
/*
|
||||
Looks like we intended to emit events when we got cancelations, but
|
||||
we never put anything in the _incomingRequests object, and the request
|
||||
itself doesn't use events anyway so if we were to wire up cancellations,
|
||||
they probably ought to use the same callback interface. I'm leaving them
|
||||
disabled for now while converting this file to typescript.
|
||||
if (this._incomingRequests[deviceId]
|
||||
&& this._incomingRequests[deviceId][content.request_id]) {
|
||||
logger.info(
|
||||
"received request cancellation for secret (" + sender +
|
||||
", " + deviceId + ", " + content.request_id + ")",
|
||||
);
|
||||
this.baseApis.emit("crypto.secrets.requestCancelled", {
|
||||
user_id: sender,
|
||||
device_id: deviceId,
|
||||
request_id: content.request_id,
|
||||
});
|
||||
}
|
||||
*/
|
||||
} else if (content.action === "request") {
|
||||
if (deviceId === this.baseApis.deviceId) {
|
||||
// no point in trying to send ourself the secret
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we have the secret
|
||||
logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
|
||||
if (!this.cryptoCallbacks.onSecretRequested) {
|
||||
return;
|
||||
}
|
||||
const secret = await this.cryptoCallbacks.onSecretRequested(
|
||||
sender,
|
||||
deviceId,
|
||||
content.request_id,
|
||||
content.name,
|
||||
this.baseApis.checkDeviceTrust(sender, deviceId),
|
||||
);
|
||||
if (secret) {
|
||||
logger.info(`Preparing ${content.name} secret for ${deviceId}`);
|
||||
const payload = {
|
||||
type: "m.secret.send",
|
||||
content: {
|
||||
request_id: content.request_id,
|
||||
secret: secret,
|
||||
},
|
||||
};
|
||||
const encryptedContent: IEncryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
|
||||
ciphertext: {},
|
||||
[ToDeviceMessageId]: uuidv4(),
|
||||
};
|
||||
await olmlib.ensureOlmSessionsForDevices(
|
||||
this.baseApis.crypto!.olmDevice,
|
||||
this.baseApis,
|
||||
new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
|
||||
);
|
||||
await olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
this.baseApis.getUserId()!,
|
||||
this.baseApis.deviceId!,
|
||||
this.baseApis.crypto!.olmDevice,
|
||||
sender,
|
||||
this.baseApis.getStoredDevice(sender, deviceId)!,
|
||||
payload,
|
||||
);
|
||||
const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
|
||||
|
||||
logger.info(`Sending ${content.name} secret for ${deviceId}`);
|
||||
this.baseApis.sendToDevice("m.room.encrypted", contentMap);
|
||||
} else {
|
||||
logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onSecretReceived(event: MatrixEvent): void {
|
||||
if (event.getSender() !== this.baseApis.getUserId()) {
|
||||
// we shouldn't be receiving secrets from anyone else, so ignore
|
||||
// because someone could be trying to send us bogus data
|
||||
return;
|
||||
}
|
||||
|
||||
if (!olmlib.isOlmEncrypted(event)) {
|
||||
logger.error("secret event not properly encrypted");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey() || "",
|
||||
);
|
||||
if (senderKeyUser !== event.getSender()) {
|
||||
logger.error("sending device does not belong to the user it claims to be from");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("got secret share for request", content.request_id);
|
||||
const requestControl = this.requests.get(content.request_id);
|
||||
if (requestControl) {
|
||||
// make sure that the device that sent it is one of the devices that
|
||||
// we requested from
|
||||
const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey()!,
|
||||
);
|
||||
if (!deviceInfo) {
|
||||
logger.log("secret share from unknown device with key", event.getSenderKey());
|
||||
return;
|
||||
}
|
||||
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
|
||||
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
|
||||
return;
|
||||
}
|
||||
// unsure that the sender is trusted. In theory, this check is
|
||||
// unnecessary since we only accept secret shares from devices that
|
||||
// we requested from, but it doesn't hurt.
|
||||
const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
|
||||
if (!deviceTrust.isVerified()) {
|
||||
logger.log("secret share from unverified device");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
|
||||
requestControl.deferred.resolve(content.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-502
@@ -14,570 +14,127 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { ICryptoCallbacks } from ".";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { MatrixClient } from "../client";
|
||||
import {
|
||||
SecretStorageKeyDescription,
|
||||
SecretStorageKeyTuple,
|
||||
SecretStorageKeyObject,
|
||||
AddSecretStorageKeyOpts,
|
||||
AccountDataClient,
|
||||
ServerSideSecretStorage,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "../secret-storage";
|
||||
import { ISecretRequest, SecretSharing } from "./SecretSharing";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import * as olmlib from "./olmlib";
|
||||
import { randomString } from "../randomstring";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
||||
import { ICryptoCallbacks, IEncryptedContent } from ".";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client";
|
||||
import { IAddSecretStorageKeyOpts } from "./api";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { defer, IDeferred } from "../utils";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage";
|
||||
/* re-exports for backwards compatibility */
|
||||
export type {
|
||||
AccountDataClient as IAccountDataClient,
|
||||
SecretStorageKeyTuple,
|
||||
SecretStorageKeyObject,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
} from "../secret-storage";
|
||||
|
||||
export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
|
||||
|
||||
// Some of the key functions use a tuple and some use an object...
|
||||
export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription];
|
||||
export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription };
|
||||
|
||||
export interface ISecretRequest {
|
||||
requestId: string;
|
||||
promise: Promise<string>;
|
||||
cancel: (reason: string) => void;
|
||||
}
|
||||
|
||||
export interface IAccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> {
|
||||
// Subset of MatrixClient (which also uses any for the event content)
|
||||
getAccountDataFromServer: <T extends { [k: string]: any }>(eventType: string) => Promise<T>;
|
||||
getAccountData: (eventType: string) => IContent | null;
|
||||
setAccountData: (eventType: string, content: any) => Promise<{}>;
|
||||
}
|
||||
|
||||
interface ISecretRequestInternal {
|
||||
name: string;
|
||||
devices: string[];
|
||||
deferred: IDeferred<string>;
|
||||
}
|
||||
|
||||
interface IDecryptors {
|
||||
encrypt: (plaintext: string) => Promise<IEncryptedPayload>;
|
||||
decrypt: (ciphertext: IEncryptedPayload) => Promise<string>;
|
||||
}
|
||||
|
||||
interface ISecretInfo {
|
||||
encrypted: {
|
||||
[keyId: string]: IEncryptedPayload;
|
||||
};
|
||||
}
|
||||
export type { ISecretRequest } from "./SecretSharing";
|
||||
|
||||
/**
|
||||
* Implements Secure Secret Storage and Sharing (MSC1946)
|
||||
*
|
||||
* @deprecated This is just a backwards-compatibility hack which will be removed soon.
|
||||
* Use {@link SecretStorage.ServerSideSecretStorageImpl} from `../secret-storage` and/or {@link SecretSharing} from `./SecretSharing`.
|
||||
*/
|
||||
export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> {
|
||||
private requests = new Map<string, ISecretRequestInternal>();
|
||||
export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> implements ServerSideSecretStorage {
|
||||
private readonly storageImpl: ServerSideSecretStorageImpl;
|
||||
private readonly sharingImpl: SecretSharing;
|
||||
|
||||
// In it's pure javascript days, this was relying on some proper Javascript-style
|
||||
// In its pure javascript days, this was relying on some proper Javascript-style
|
||||
// type-abuse where sometimes we'd pass in a fake client object with just the account
|
||||
// data methods implemented, which is all this class needs unless you use the secret
|
||||
// sharing code, so it was fine. As a low-touch TypeScript migration, this now has
|
||||
// sharing code, so it was fine. As a low-touch TypeScript migration, we added
|
||||
// an extra, optional param for a real matrix client, so you can not pass it as long
|
||||
// as you don't request any secrets.
|
||||
// A better solution would probably be to split this class up into secret storage and
|
||||
// secret sharing which are really two separate things, even though they share an MSC.
|
||||
public constructor(
|
||||
private readonly accountDataAdapter: IAccountDataClient,
|
||||
private readonly cryptoCallbacks: ICryptoCallbacks,
|
||||
private readonly baseApis: B,
|
||||
) {}
|
||||
//
|
||||
// Nowadays, the whole class is scheduled for destruction, once we get rid of the legacy
|
||||
// Crypto impl that exposes it.
|
||||
public constructor(accountDataAdapter: AccountDataClient, cryptoCallbacks: ICryptoCallbacks, baseApis: B) {
|
||||
this.storageImpl = new ServerSideSecretStorageImpl(accountDataAdapter, cryptoCallbacks);
|
||||
this.sharingImpl = new SecretSharing(baseApis as MatrixClient, cryptoCallbacks);
|
||||
}
|
||||
|
||||
public async getDefaultKeyId(): Promise<string | null> {
|
||||
const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>(
|
||||
"m.secret_storage.default_key",
|
||||
);
|
||||
if (!defaultKey) return null;
|
||||
return defaultKey.key;
|
||||
public getDefaultKeyId(): Promise<string | null> {
|
||||
return this.storageImpl.getDefaultKeyId();
|
||||
}
|
||||
|
||||
public setDefaultKeyId(keyId: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const listener = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
|
||||
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.accountDataAdapter.on(ClientEvent.AccountData, listener);
|
||||
|
||||
this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => {
|
||||
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
return this.storageImpl.setDefaultKeyId(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key for encrypting secrets.
|
||||
*
|
||||
* @param algorithm - the algorithm used by the key.
|
||||
* @param opts - the options for the algorithm. The properties used
|
||||
* depend on the algorithm given.
|
||||
* @param keyId - the ID of the key. If not given, a random
|
||||
* ID will be generated.
|
||||
*
|
||||
* @returns An object with:
|
||||
* keyId: the ID of the key
|
||||
* keyInfo: details about the key (iv, mac, passphrase)
|
||||
*/
|
||||
public async addKey(
|
||||
public addKey(
|
||||
algorithm: string,
|
||||
opts: IAddSecretStorageKeyOpts = {},
|
||||
opts: AddSecretStorageKeyOpts = {},
|
||||
keyId?: string,
|
||||
): Promise<SecretStorageKeyObject> {
|
||||
if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
throw new Error(`Unknown key algorithm ${algorithm}`);
|
||||
}
|
||||
|
||||
const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1;
|
||||
|
||||
if (opts.name) {
|
||||
keyInfo.name = opts.name;
|
||||
}
|
||||
|
||||
if (opts.passphrase) {
|
||||
keyInfo.passphrase = opts.passphrase;
|
||||
}
|
||||
if (opts.key) {
|
||||
const { iv, mac } = await calculateKeyCheck(opts.key);
|
||||
keyInfo.iv = iv;
|
||||
keyInfo.mac = mac;
|
||||
}
|
||||
|
||||
if (!keyId) {
|
||||
do {
|
||||
keyId = randomString(32);
|
||||
} while (
|
||||
await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
`m.secret_storage.key.${keyId}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
|
||||
|
||||
return {
|
||||
keyId,
|
||||
keyInfo,
|
||||
};
|
||||
return this.storageImpl.addKey(algorithm, opts, keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key information for a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns If the key was found, the return value is an array of
|
||||
* the form [keyId, keyInfo]. Otherwise, null is returned.
|
||||
* XXX: why is this an array when addKey returns an object?
|
||||
*/
|
||||
public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
|
||||
if (!keyId) {
|
||||
keyId = await this.getDefaultKeyId();
|
||||
}
|
||||
if (!keyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
return keyInfo ? [keyId, keyInfo] : null;
|
||||
public getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
|
||||
return this.storageImpl.getKey(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether we have a key with a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns Whether we have the key.
|
||||
*/
|
||||
public async hasKey(keyId?: string): Promise<boolean> {
|
||||
return Boolean(await this.getKey(keyId));
|
||||
public hasKey(keyId?: string): Promise<boolean> {
|
||||
return this.storageImpl.hasKey(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a key matches what we expect based on the key info
|
||||
*
|
||||
* @param key - the key to check
|
||||
* @param info - the key info
|
||||
*
|
||||
* @returns whether or not the key matches
|
||||
*/
|
||||
public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
|
||||
if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (info.mac) {
|
||||
const { mac } = await calculateKeyCheck(key, info.iv);
|
||||
return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, "");
|
||||
} else {
|
||||
// if we have no information, we have to assume the key is right
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unknown algorithm");
|
||||
}
|
||||
public checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
|
||||
return this.storageImpl.checkKey(key, info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an encrypted secret on the server
|
||||
*
|
||||
* @param name - The name of the secret
|
||||
* @param secret - The secret contents.
|
||||
* @param keys - The IDs of the keys to use to encrypt the secret
|
||||
* or null/undefined to use the default key.
|
||||
*/
|
||||
public async store(name: string, secret: string, keys?: string[] | null): Promise<void> {
|
||||
const encrypted: Record<string, IEncryptedPayload> = {};
|
||||
|
||||
if (!keys) {
|
||||
const defaultKeyId = await this.getDefaultKeyId();
|
||||
if (!defaultKeyId) {
|
||||
throw new Error("No keys specified and no default key present");
|
||||
}
|
||||
keys = [defaultKeyId];
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
throw new Error("Zero keys given to encrypt with!");
|
||||
}
|
||||
|
||||
for (const keyId of keys) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
if (!keyInfo) {
|
||||
throw new Error("Unknown key: " + keyId);
|
||||
}
|
||||
|
||||
// encrypt secret, based on the algorithm
|
||||
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
const keys = { [keyId]: keyInfo };
|
||||
const [, encryption] = await this.getSecretStorageKey(keys, name);
|
||||
encrypted[keyId] = await encryption.encrypt(secret);
|
||||
} else {
|
||||
logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm);
|
||||
// do nothing if we don't understand the encryption algorithm
|
||||
}
|
||||
}
|
||||
|
||||
// save encrypted secret
|
||||
await this.accountDataAdapter.setAccountData(name, { encrypted });
|
||||
public store(name: string, secret: string, keys?: string[] | null): Promise<void> {
|
||||
return this.storageImpl.store(name, secret, keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret from storage.
|
||||
*
|
||||
* @param name - the name of the secret
|
||||
*
|
||||
* @returns the contents of the secret
|
||||
*/
|
||||
public async get(name: string): Promise<string | undefined> {
|
||||
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
|
||||
if (!secretInfo) {
|
||||
return;
|
||||
}
|
||||
if (!secretInfo.encrypted) {
|
||||
throw new Error("Content is not encrypted!");
|
||||
}
|
||||
|
||||
// get possible keys to decrypt
|
||||
const keys: Record<string, SecretStorageKeyDescription> = {};
|
||||
for (const keyId of Object.keys(secretInfo.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
// only use keys we understand the encryption algorithm of
|
||||
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
|
||||
keys[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(keys).length === 0) {
|
||||
throw new Error(
|
||||
`Could not decrypt ${name} because none of ` +
|
||||
`the keys it is encrypted with are for a supported algorithm`,
|
||||
);
|
||||
}
|
||||
|
||||
// fetch private key from app
|
||||
const [keyId, decryption] = await this.getSecretStorageKey(keys, name);
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
|
||||
return decryption.decrypt(encInfo);
|
||||
public get(name: string): Promise<string | undefined> {
|
||||
return this.storageImpl.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a secret is stored on the server.
|
||||
*
|
||||
* @param name - the name of the secret
|
||||
*
|
||||
* @returns map of key name to key info the secret is encrypted
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* key
|
||||
*/
|
||||
public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
|
||||
// check if secret exists
|
||||
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
|
||||
if (!secretInfo?.encrypted) return null;
|
||||
|
||||
const ret: Record<string, SecretStorageKeyDescription> = {};
|
||||
|
||||
// filter secret encryption keys with supported algorithm
|
||||
for (const keyId of Object.keys(secretInfo.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
if (!keyInfo) continue;
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
|
||||
// only use keys we understand the encryption algorithm of
|
||||
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
|
||||
ret[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(ret).length ? ret : null;
|
||||
return this.storageImpl.isStored(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a secret from another device
|
||||
*
|
||||
* @param name - the name of the secret to request
|
||||
* @param devices - the devices to request the secret from
|
||||
*/
|
||||
public request(this: SecretStorage<MatrixClient>, name: string, devices: string[]): ISecretRequest {
|
||||
const requestId = this.baseApis.makeTxnId();
|
||||
|
||||
const deferred = defer<string>();
|
||||
this.requests.set(requestId, { name, devices, deferred });
|
||||
|
||||
const cancel = (reason: string): void => {
|
||||
// send cancellation event
|
||||
const cancelData = {
|
||||
action: "request_cancellation",
|
||||
requesting_device_id: this.baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
};
|
||||
const toDevice: Map<string, typeof cancelData> = new Map();
|
||||
for (const device of devices) {
|
||||
toDevice.set(device, cancelData);
|
||||
}
|
||||
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
|
||||
|
||||
// and reject the promise so that anyone waiting on it will be
|
||||
// notified
|
||||
deferred.reject(new Error(reason || "Cancelled"));
|
||||
};
|
||||
|
||||
// send request to devices
|
||||
const requestData = {
|
||||
name,
|
||||
action: "request",
|
||||
requesting_device_id: this.baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
[ToDeviceMessageId]: uuidv4(),
|
||||
};
|
||||
const toDevice: Map<string, typeof requestData> = new Map();
|
||||
for (const device of devices) {
|
||||
toDevice.set(device, requestData);
|
||||
}
|
||||
logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
|
||||
this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
|
||||
|
||||
return {
|
||||
requestId,
|
||||
promise: deferred.promise,
|
||||
cancel,
|
||||
};
|
||||
public request(name: string, devices: string[]): ISecretRequest {
|
||||
return this.sharingImpl.request(name, devices);
|
||||
}
|
||||
|
||||
public async onRequestReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): Promise<void> {
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent();
|
||||
if (
|
||||
sender !== this.baseApis.getUserId() ||
|
||||
!(content.name && content.action && content.requesting_device_id && content.request_id)
|
||||
) {
|
||||
// ignore requests from anyone else, for now
|
||||
return;
|
||||
}
|
||||
const deviceId = content.requesting_device_id;
|
||||
// check if it's a cancel
|
||||
if (content.action === "request_cancellation") {
|
||||
/*
|
||||
Looks like we intended to emit events when we got cancelations, but
|
||||
we never put anything in the _incomingRequests object, and the request
|
||||
itself doesn't use events anyway so if we were to wire up cancellations,
|
||||
they probably ought to use the same callback interface. I'm leaving them
|
||||
disabled for now while converting this file to typescript.
|
||||
if (this._incomingRequests[deviceId]
|
||||
&& this._incomingRequests[deviceId][content.request_id]) {
|
||||
logger.info(
|
||||
"received request cancellation for secret (" + sender +
|
||||
", " + deviceId + ", " + content.request_id + ")",
|
||||
);
|
||||
this.baseApis.emit("crypto.secrets.requestCancelled", {
|
||||
user_id: sender,
|
||||
device_id: deviceId,
|
||||
request_id: content.request_id,
|
||||
});
|
||||
}
|
||||
*/
|
||||
} else if (content.action === "request") {
|
||||
if (deviceId === this.baseApis.deviceId) {
|
||||
// no point in trying to send ourself the secret
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we have the secret
|
||||
logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
|
||||
if (!this.cryptoCallbacks.onSecretRequested) {
|
||||
return;
|
||||
}
|
||||
const secret = await this.cryptoCallbacks.onSecretRequested(
|
||||
sender,
|
||||
deviceId,
|
||||
content.request_id,
|
||||
content.name,
|
||||
this.baseApis.checkDeviceTrust(sender, deviceId),
|
||||
);
|
||||
if (secret) {
|
||||
logger.info(`Preparing ${content.name} secret for ${deviceId}`);
|
||||
const payload = {
|
||||
type: "m.secret.send",
|
||||
content: {
|
||||
request_id: content.request_id,
|
||||
secret: secret,
|
||||
},
|
||||
};
|
||||
const encryptedContent: IEncryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
|
||||
ciphertext: {},
|
||||
[ToDeviceMessageId]: uuidv4(),
|
||||
};
|
||||
await olmlib.ensureOlmSessionsForDevices(
|
||||
this.baseApis.crypto!.olmDevice,
|
||||
this.baseApis,
|
||||
new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
|
||||
);
|
||||
await olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
this.baseApis.getUserId()!,
|
||||
this.baseApis.deviceId!,
|
||||
this.baseApis.crypto!.olmDevice,
|
||||
sender,
|
||||
this.baseApis.getStoredDevice(sender, deviceId)!,
|
||||
payload,
|
||||
);
|
||||
const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
|
||||
|
||||
logger.info(`Sending ${content.name} secret for ${deviceId}`);
|
||||
this.baseApis.sendToDevice("m.room.encrypted", contentMap);
|
||||
} else {
|
||||
logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
|
||||
}
|
||||
}
|
||||
public onRequestReceived(event: MatrixEvent): Promise<void> {
|
||||
return this.sharingImpl.onRequestReceived(event);
|
||||
}
|
||||
|
||||
public onSecretReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): void {
|
||||
if (event.getSender() !== this.baseApis.getUserId()) {
|
||||
// we shouldn't be receiving secrets from anyone else, so ignore
|
||||
// because someone could be trying to send us bogus data
|
||||
return;
|
||||
}
|
||||
|
||||
if (!olmlib.isOlmEncrypted(event)) {
|
||||
logger.error("secret event not properly encrypted");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey() || "",
|
||||
);
|
||||
if (senderKeyUser !== event.getSender()) {
|
||||
logger.error("sending device does not belong to the user it claims to be from");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("got secret share for request", content.request_id);
|
||||
const requestControl = this.requests.get(content.request_id);
|
||||
if (requestControl) {
|
||||
// make sure that the device that sent it is one of the devices that
|
||||
// we requested from
|
||||
const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey()!,
|
||||
);
|
||||
if (!deviceInfo) {
|
||||
logger.log("secret share from unknown device with key", event.getSenderKey());
|
||||
return;
|
||||
}
|
||||
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
|
||||
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
|
||||
return;
|
||||
}
|
||||
// unsure that the sender is trusted. In theory, this check is
|
||||
// unnecessary since we only accept secret shares from devices that
|
||||
// we requested from, but it doesn't hurt.
|
||||
const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
|
||||
if (!deviceTrust.isVerified()) {
|
||||
logger.log("secret share from unverified device");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
|
||||
requestControl.deferred.resolve(content.secret);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSecretStorageKey(
|
||||
keys: Record<string, SecretStorageKeyDescription>,
|
||||
name: string,
|
||||
): Promise<[string, IDecryptors]> {
|
||||
if (!this.cryptoCallbacks.getSecretStorageKey) {
|
||||
throw new Error("No getSecretStorageKey callback supplied");
|
||||
}
|
||||
|
||||
const returned = await this.cryptoCallbacks.getSecretStorageKey({ keys }, name);
|
||||
|
||||
if (!returned) {
|
||||
throw new Error("getSecretStorageKey callback returned falsey");
|
||||
}
|
||||
if (returned.length < 2) {
|
||||
throw new Error("getSecretStorageKey callback returned invalid data");
|
||||
}
|
||||
|
||||
const [keyId, privateKey] = returned;
|
||||
if (!keys[keyId]) {
|
||||
throw new Error("App returned unknown key from getSecretStorageKey!");
|
||||
}
|
||||
|
||||
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
const decryption = {
|
||||
encrypt: function (secret: string): Promise<IEncryptedPayload> {
|
||||
return encryptAES(secret, privateKey, name);
|
||||
},
|
||||
decrypt: function (encInfo: IEncryptedPayload): Promise<string> {
|
||||
return decryptAES(encInfo, privateKey, name);
|
||||
},
|
||||
};
|
||||
return [keyId, decryption];
|
||||
} else {
|
||||
throw new Error("Unknown key type: " + keys[keyId].algorithm);
|
||||
}
|
||||
public onSecretReceived(event: MatrixEvent): void {
|
||||
this.sharingImpl.onSecretReceived(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +192,23 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
});
|
||||
}
|
||||
|
||||
// check that the device that encrypted the event belongs to the user
|
||||
// that the event claims it's from. We need to make sure that our
|
||||
// device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out. Some event handlers, such as
|
||||
// secret sharing, may be more strict and reject events that come from
|
||||
// unknown devices.
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
//
|
||||
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
|
||||
// more strict and reject events that come from unknown devices.
|
||||
//
|
||||
// This is a defence against the following scenario:
|
||||
//
|
||||
// * Alice has verified Bob and Mallory.
|
||||
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
|
||||
// senderkey, but claiming to be from Bob.
|
||||
// * Mallory sends more events using that session, claiming to be from Bob.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
|
||||
// events as verified even though the sender is forged.
|
||||
//
|
||||
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
|
||||
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
|
||||
|
||||
+5
-16
@@ -16,22 +16,18 @@ limitations under the License.
|
||||
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { PassphraseInfo } from "../secret-storage";
|
||||
import type { AddSecretStorageKeyOpts } from "../secret-storage";
|
||||
|
||||
/* re-exports for backwards compatibility. */
|
||||
export {
|
||||
export { CrossSigningKey } from "../crypto-api";
|
||||
export type {
|
||||
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
|
||||
PassphraseInfo as IPassphraseInfo,
|
||||
SecretStorageKeyDescription as ISecretStorageKeyInfo,
|
||||
} from "../secret-storage";
|
||||
|
||||
// TODO: Merge this with crypto.js once converted
|
||||
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
export interface IEncryptedEventInfo {
|
||||
/**
|
||||
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
|
||||
@@ -65,7 +61,7 @@ export interface IEncryptedEventInfo {
|
||||
}
|
||||
|
||||
export interface IRecoveryKey {
|
||||
keyInfo?: IAddSecretStorageKeyOpts;
|
||||
keyInfo?: AddSecretStorageKeyOpts;
|
||||
privateKey: Uint8Array;
|
||||
encodedPrivateKey?: string;
|
||||
}
|
||||
@@ -105,13 +101,6 @@ export interface ICreateSecretStorageOpts {
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface IAddSecretStorageKeyOpts {
|
||||
pubkey?: string;
|
||||
passphrase?: PassphraseInfo;
|
||||
name?: string;
|
||||
key?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface IImportOpts {
|
||||
stage: string; // TODO: Enum
|
||||
successes: number;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Device } from "../models/device";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
|
||||
/**
|
||||
* Convert a {@link DeviceInfo} to a {@link Device}.
|
||||
* @param deviceInfo - deviceInfo to convert
|
||||
* @param userId - id of the user that owns the device.
|
||||
*/
|
||||
export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device {
|
||||
const keys = new Map<string, string>(Object.entries(deviceInfo.keys));
|
||||
const displayName = deviceInfo.getDisplayName() || undefined;
|
||||
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
if (deviceInfo.signatures) {
|
||||
for (const userId in deviceInfo.signatures) {
|
||||
signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
|
||||
}
|
||||
}
|
||||
|
||||
return new Device({
|
||||
deviceId: deviceInfo.deviceId,
|
||||
userId: userId,
|
||||
keys,
|
||||
algorithms: deviceInfo.algorithms,
|
||||
verified: deviceInfo.verified,
|
||||
signatures,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { DeviceVerification } from "../models/device";
|
||||
|
||||
export interface IDevice {
|
||||
keys: Record<string, string>;
|
||||
@@ -25,12 +26,6 @@ export interface IDevice {
|
||||
signatures?: ISignatures;
|
||||
}
|
||||
|
||||
enum DeviceVerification {
|
||||
Blocked = -1,
|
||||
Unverified = 0,
|
||||
Verified = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a user's device
|
||||
*/
|
||||
|
||||
+183
-97
@@ -34,16 +34,9 @@ import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
|
||||
import { EncryptionSetupBuilder } from "./EncryptionSetup";
|
||||
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
|
||||
import {
|
||||
IAccountDataClient,
|
||||
ISecretRequest,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
SecretStorage,
|
||||
SecretStorageKeyObject,
|
||||
SecretStorageKeyTuple,
|
||||
} from "./SecretStorage";
|
||||
import {
|
||||
IAddSecretStorageKeyOpts,
|
||||
CrossSigningKey,
|
||||
ICreateSecretStorageOpts,
|
||||
IEncryptedEventInfo,
|
||||
IImportRoomKeysOpts,
|
||||
@@ -58,7 +51,7 @@ import { keyFromPassphrase } from "./key_passphrase";
|
||||
import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey";
|
||||
import { VerificationRequest } from "./verification/request/VerificationRequest";
|
||||
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
|
||||
import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
|
||||
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
|
||||
import { IllegalMethod } from "./verification/IllegalMethod";
|
||||
import { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
|
||||
@@ -67,7 +60,7 @@ import { BackupManager } from "./backup";
|
||||
import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { ToDeviceBatch } from "../models/ToDeviceMessage";
|
||||
import {
|
||||
ClientEvent,
|
||||
@@ -83,14 +76,29 @@ import { ISyncStateData } from "../sync";
|
||||
import { CryptoStore } from "./store/base";
|
||||
import { IVerificationChannel } from "./verification/request/Channel";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IContent } from "../models/event";
|
||||
import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import { SecretStorageKeyDescription } from "../secret-storage";
|
||||
import {
|
||||
AccountDataClient,
|
||||
AddSecretStorageKeyOpts,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
SecretStorageCallbacks,
|
||||
SecretStorageKeyDescription,
|
||||
SecretStorageKeyObject,
|
||||
SecretStorageKeyTuple,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "../secret-storage";
|
||||
import { ISecretRequest } from "./SecretSharing";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -127,24 +135,10 @@ interface IInitOpts {
|
||||
pickleKey?: string;
|
||||
}
|
||||
|
||||
export interface IBootstrapCrossSigningOpts {
|
||||
/** Optional. Reset even if keys already exist. */
|
||||
setupNewCrossSigning?: boolean;
|
||||
/**
|
||||
* A function that makes the request requiring auth. Receives the auth data as an object.
|
||||
* Can be called multiple times, first with an empty authDict, to obtain the flows.
|
||||
*/
|
||||
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks {
|
||||
export interface ICryptoCallbacks extends SecretStorageCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
|
||||
getSecretStorageKey?: (
|
||||
keys: { keys: Record<string, SecretStorageKeyDescription> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
onSecretRequested?: (
|
||||
userId: string,
|
||||
@@ -252,8 +246,8 @@ export enum CryptoEvent {
|
||||
export type CryptoEventHandlerMap = {
|
||||
/**
|
||||
* Fires when a device is marked as verified/unverified/blocked/unblocked by
|
||||
* {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or
|
||||
* {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}.
|
||||
* {@link MatrixClient#setDeviceVerified | MatrixClient.setDeviceVerified} or
|
||||
* {@link MatrixClient#setDeviceBlocked | MatrixClient.setDeviceBlocked}.
|
||||
*
|
||||
* @param userId - the owner of the verified device
|
||||
* @param deviceId - the id of the verified device
|
||||
@@ -356,7 +350,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public readonly olmDevice: OlmDevice;
|
||||
public readonly deviceList: DeviceList;
|
||||
public readonly dehydrationManager: DehydrationManager;
|
||||
public readonly secretStorage: SecretStorage;
|
||||
public readonly secretStorage: LegacySecretStorage;
|
||||
|
||||
private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>;
|
||||
private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>;
|
||||
@@ -483,15 +477,15 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
// try to get key from secret storage
|
||||
const storedKey = await this.getSecret("m.megolm_backup.v1");
|
||||
const storedKey = await this.secretStorage.get("m.megolm_backup.v1");
|
||||
|
||||
if (storedKey) {
|
||||
// ensure that the key is in the right format. If not, fix the key and
|
||||
// store the fixed version
|
||||
const fixedKey = fixBackupKey(storedKey);
|
||||
if (fixedKey) {
|
||||
const keys = await this.getSecretStorageKey();
|
||||
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
const keys = await this.secretStorage.getKey();
|
||||
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
}
|
||||
|
||||
return olmlib.decodeBase64(fixedKey || storedKey);
|
||||
@@ -529,7 +523,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks);
|
||||
// Yes, we pass the client twice here: see SecretStorage
|
||||
this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis);
|
||||
this.secretStorage = new LegacySecretStorage(baseApis as AccountDataClient, cryptoCallbacks, baseApis);
|
||||
this.dehydrationManager = new DehydrationManager(this);
|
||||
|
||||
// Assuming no app-supplied callback, default to getting from SSSS.
|
||||
@@ -612,18 +606,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns True if trusting cross-signed devices
|
||||
*/
|
||||
public getTrustCrossSignedDevices(): boolean {
|
||||
return this.trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi#getTrustCrossSignedDevices}.
|
||||
*/
|
||||
public getCryptoTrustCrossSignedDevices(): boolean {
|
||||
return this.trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* See getCryptoTrustCrossSignedDevices
|
||||
|
||||
* This may be set before initCrypto() is called to ensure no races occur.
|
||||
*
|
||||
* @param val - True to trust cross-signed devices
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
public setTrustCrossSignedDevices(val: boolean): void {
|
||||
this.trustCrossSignedDevices = val;
|
||||
|
||||
for (const userId of this.deviceList.getKnownUserIds()) {
|
||||
@@ -641,6 +640,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
this.setTrustCrossSignedDevices(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recovery key from a user-supplied passphrase.
|
||||
*
|
||||
@@ -761,7 +767,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public async bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning,
|
||||
}: IBootstrapCrossSigningOpts = {}): Promise<void> {
|
||||
}: BootstrapCrossSigningOpts = {}): Promise<void> {
|
||||
logger.log("Bootstrapping cross-signing");
|
||||
|
||||
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
|
||||
@@ -834,10 +840,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// done as part of setting up secret storage later.
|
||||
const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys;
|
||||
if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) {
|
||||
const secretStorage = new SecretStorage(
|
||||
const secretStorage = new ServerSideSecretStorageImpl(
|
||||
builder.accountDataClientAdapter,
|
||||
builder.ssssCryptoCallbacks,
|
||||
undefined,
|
||||
);
|
||||
if (await secretStorage.hasKey()) {
|
||||
logger.log("Storing new cross-signing private keys in secret storage");
|
||||
@@ -900,17 +905,16 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
logger.log("Bootstrapping Secure Secret Storage");
|
||||
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
|
||||
const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
|
||||
const secretStorage = new SecretStorage(
|
||||
const secretStorage = new ServerSideSecretStorageImpl(
|
||||
builder.accountDataClientAdapter,
|
||||
builder.ssssCryptoCallbacks,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// the ID of the new SSSS key, if we create one
|
||||
let newKeyId: string | null = null;
|
||||
|
||||
// create a new SSSS key and set it as default
|
||||
const createSSSS = async (opts: IAddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
|
||||
const createSSSS = async (opts: AddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
|
||||
if (privateKey) {
|
||||
opts.key = privateKey;
|
||||
}
|
||||
@@ -959,7 +963,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
};
|
||||
|
||||
const oldSSSSKey = await this.getSecretStorageKey();
|
||||
const oldSSSSKey = await this.secretStorage.getKey();
|
||||
const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null];
|
||||
const storageExists =
|
||||
!setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES;
|
||||
@@ -984,7 +988,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// secrets using it, in theory. We could move them to the new key but a)
|
||||
// that would mean we'd need to prompt for the old passphrase, and b)
|
||||
// it's not clear that would be the right thing to do anyway.
|
||||
const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
|
||||
const { keyInfo = {} as AddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
|
||||
newKeyId = await createSSSS(keyInfo, privateKey);
|
||||
} else if (!storageExists && keyBackupInfo) {
|
||||
// we have an existing backup, but no SSSS
|
||||
@@ -995,7 +999,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.());
|
||||
|
||||
// create a new SSSS key and use the backup key as the new SSSS key
|
||||
const opts = {} as IAddSecretStorageKeyOpts;
|
||||
const opts = {} as AddSecretStorageKeyOpts;
|
||||
|
||||
if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) {
|
||||
// FIXME: ???
|
||||
@@ -1109,30 +1113,48 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
logger.log("Secure Secret Storage ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#addKey}.
|
||||
*/
|
||||
public addSecretStorageKey(
|
||||
algorithm: string,
|
||||
opts: IAddSecretStorageKeyOpts,
|
||||
opts: AddSecretStorageKeyOpts,
|
||||
keyID?: string,
|
||||
): Promise<SecretStorageKeyObject> {
|
||||
return this.secretStorage.addKey(algorithm, opts, keyID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#hasKey}.
|
||||
*/
|
||||
public hasSecretStorageKey(keyID?: string): Promise<boolean> {
|
||||
return this.secretStorage.hasKey(keyID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getKey}.
|
||||
*/
|
||||
public getSecretStorageKey(keyID?: string): Promise<SecretStorageKeyTuple | null> {
|
||||
return this.secretStorage.getKey(keyID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#store}.
|
||||
*/
|
||||
public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
|
||||
return this.secretStorage.store(name, secret, keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#get}.
|
||||
*/
|
||||
public getSecret(name: string): Promise<string | undefined> {
|
||||
return this.secretStorage.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#isStored}.
|
||||
*/
|
||||
public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
|
||||
return this.secretStorage.isStored(name);
|
||||
}
|
||||
@@ -1144,14 +1166,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.secretStorage.request(name, devices);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#getDefaultKeyId}.
|
||||
*/
|
||||
public getDefaultSecretStorageKeyId(): Promise<string | null> {
|
||||
return this.secretStorage.getDefaultKeyId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#setDefaultKeyId}.
|
||||
*/
|
||||
public setDefaultSecretStorageKeyId(k: string): Promise<void> {
|
||||
return this.secretStorage.setDefaultKeyId(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link MatrixClient#secretStorage} and {@link SecretStorage.ServerSideSecretStorage#checkKey}.
|
||||
*/
|
||||
public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
|
||||
return this.secretStorage.checkKey(key, info);
|
||||
}
|
||||
@@ -1389,6 +1420,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns the key ID
|
||||
*/
|
||||
public getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
|
||||
return Promise.resolve(this.getCrossSigningId(type));
|
||||
}
|
||||
|
||||
// old name, for backwards compatibility
|
||||
public getCrossSigningId(type: string): string | null {
|
||||
return this.crossSigningInfo.getId(type);
|
||||
}
|
||||
@@ -1422,10 +1458,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* Check whether a given device is trusted.
|
||||
*
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param userId - The ID of the user whose device is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
public async getDeviceVerificationStatus(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<DeviceVerificationStatus | null> {
|
||||
const device = this.deviceList.getStoredDevice(userId, deviceId);
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
return this.checkDeviceInfoTrust(userId, device);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
const device = this.deviceList.getStoredDevice(userId, deviceId);
|
||||
@@ -1438,7 +1486,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param device - The device info object to check
|
||||
*
|
||||
* @returns
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
|
||||
const trustedLocally = !!device?.isVerified();
|
||||
@@ -1787,8 +1835,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param value - whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For
|
||||
* internal code, set {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
|
||||
this.globalBlacklistUnverifiedDevices = value;
|
||||
@@ -1797,8 +1844,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* @returns whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For
|
||||
* internal code, reference {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public getGlobalBlacklistUnverifiedDevices(): boolean {
|
||||
return this.globalBlacklistUnverifiedDevices;
|
||||
@@ -1823,24 +1869,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the current one_time_key count which will be handled later (in a call of
|
||||
* onSyncCompleted). The count is e.g. coming from a /sync response.
|
||||
*
|
||||
* @param currentCount - The current count of one_time_keys to be stored
|
||||
*/
|
||||
public updateOneTimeKeyCount(currentCount: number): void {
|
||||
if (isFinite(currentCount)) {
|
||||
this.oneTimeKeyCount = currentCount;
|
||||
} else {
|
||||
throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
|
||||
}
|
||||
}
|
||||
|
||||
public setNeedsNewFallback(needsNewFallback: boolean): void {
|
||||
this.needsNewFallback = needsNewFallback;
|
||||
}
|
||||
|
||||
public getNeedsNewFallback(): boolean {
|
||||
return !!this.needsNewFallback;
|
||||
}
|
||||
@@ -1976,7 +2004,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
fallbackJson["signed_curve25519:" + keyId] = k;
|
||||
promises.push(this.signObject(k));
|
||||
}
|
||||
this.setNeedsNewFallback(false);
|
||||
this.needsNewFallback = false;
|
||||
}
|
||||
|
||||
const oneTimeKeys = await this.olmDevice.getOneTimeKeys();
|
||||
@@ -2040,6 +2068,54 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.deviceList.getStoredDevicesForUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
|
||||
const deviceMapByUserId = new Map<string, Map<string, Device>>();
|
||||
// Keep the users without device to download theirs keys
|
||||
const usersWithoutDeviceInfo: string[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
const deviceInfos = await this.getStoredDevicesForUser(userId);
|
||||
// If there are device infos for a userId, we transform it into a map
|
||||
// Else, the keys will be downloaded after
|
||||
if (deviceInfos) {
|
||||
const deviceMap = new Map(
|
||||
// Convert DeviceInfo to Device
|
||||
deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]),
|
||||
);
|
||||
deviceMapByUserId.set(userId, deviceMap);
|
||||
} else {
|
||||
usersWithoutDeviceInfo.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Download device info for users without device infos
|
||||
if (downloadUncached && usersWithoutDeviceInfo.length > 0) {
|
||||
const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo);
|
||||
|
||||
newDeviceInfoMap.forEach((deviceInfoMap, userId) => {
|
||||
const deviceMap = new Map<string, Device>();
|
||||
// Convert DeviceInfo to Device
|
||||
deviceInfoMap.forEach((deviceInfo, deviceId) =>
|
||||
deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)),
|
||||
);
|
||||
|
||||
// Put the new device infos into the returned map
|
||||
deviceMapByUserId.set(userId, deviceMap);
|
||||
});
|
||||
}
|
||||
|
||||
return deviceMapByUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored keys for a single device
|
||||
*
|
||||
@@ -2693,7 +2769,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @returns resolves once the sessions are complete, to
|
||||
* an Object mapping from userId to deviceId to
|
||||
* {@link OlmSessionResult}
|
||||
* `IOlmSessionResult`
|
||||
*/
|
||||
public ensureOlmSessionsForUsers(
|
||||
users: string[],
|
||||
@@ -2917,21 +2993,12 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the notification from /sync or /keys/changes that device lists have
|
||||
* Handle the notification from /sync that device lists have
|
||||
* been changed.
|
||||
*
|
||||
* @param syncData - Object containing sync tokens associated with this sync
|
||||
* @param syncDeviceLists - device_lists field from /sync, or response from
|
||||
* /keys/changes
|
||||
* @param deviceLists - device_lists field from /sync
|
||||
*/
|
||||
public async handleDeviceListChanges(
|
||||
syncData: ISyncStateData,
|
||||
syncDeviceLists: Required<ISyncResponse>["device_lists"],
|
||||
): Promise<void> {
|
||||
// Initial syncs don't have device change lists. We'll either get the complete list
|
||||
// of changes for the interval or will have invalidated everything in willProcessSync
|
||||
if (!syncData.oldSyncToken) return;
|
||||
|
||||
public async processDeviceLists(deviceLists: IDeviceLists): Promise<void> {
|
||||
// Here, we're relying on the fact that we only ever save the sync data after
|
||||
// sucessfully saving the device list data, so we're guaranteed that the device
|
||||
// list store is at least as fresh as the sync token from the sync store, ie.
|
||||
@@ -2940,7 +3007,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// If we didn't make this assumption, we'd have to use the /keys/changes API
|
||||
// to get key changes between the sync token in the device list and the 'old'
|
||||
// sync token used here to make sure we didn't miss any.
|
||||
await this.evalDeviceListChanges(syncDeviceLists);
|
||||
await this.evalDeviceListChanges(deviceLists);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3221,14 +3288,33 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
});
|
||||
}
|
||||
|
||||
public preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
|
||||
const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0;
|
||||
this.updateOneTimeKeyCount(currentCount);
|
||||
return Promise.resolve();
|
||||
/**
|
||||
* Stores the current one_time_key count which will be handled later (in a call of
|
||||
* onSyncCompleted).
|
||||
*
|
||||
* @param currentCount - The current count of one_time_keys to be stored
|
||||
*/
|
||||
private updateOneTimeKeyCount(currentCount: number): void {
|
||||
if (isFinite(currentCount)) {
|
||||
this.oneTimeKeyCount = currentCount;
|
||||
} else {
|
||||
throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
|
||||
}
|
||||
}
|
||||
|
||||
public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
|
||||
this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519"));
|
||||
public processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void> {
|
||||
if (oneTimeKeysCounts !== undefined) {
|
||||
this.updateOneTimeKeyCount(oneTimeKeysCounts["signed_curve25519"] || 0);
|
||||
}
|
||||
|
||||
if (unusedFallbackKeys !== undefined) {
|
||||
// If `unusedFallbackKeys` is defined, that means `device_unused_fallback_key_types`
|
||||
// is present in the sync response, which indicates that the server supports fallback keys.
|
||||
//
|
||||
// If there's no unused signed_curve25519 fallback key, we need a new one.
|
||||
this.needsNewFallback = !unusedFallbackKeys.includes("signed_curve25519");
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -3784,7 +3870,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param algorithm - crypto algorithm
|
||||
*
|
||||
* @throws {@link DecryptionError} if the algorithm is unknown
|
||||
* @throws `DecryptionError` if the algorithm is unknown
|
||||
*/
|
||||
public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm {
|
||||
let decryptors: Map<string, DecryptionAlgorithm> | undefined;
|
||||
|
||||
@@ -211,7 +211,7 @@ export interface OutgoingRoomKeyRequest {
|
||||
*/
|
||||
requestBody: IRoomKeyRequestBody;
|
||||
/**
|
||||
* current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager})
|
||||
* current state of this request
|
||||
*/
|
||||
state: RoomKeyRequestState;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import * as utils from "../../utils";
|
||||
import { deepCompare } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
@@ -158,7 +158,7 @@ export class Backend implements CryptoStore {
|
||||
|
||||
const existing = cursor.value;
|
||||
|
||||
if (utils.deepCompare(existing.requestBody, requestBody)) {
|
||||
if (deepCompare(existing.requestBody, requestBody)) {
|
||||
// got a match
|
||||
callback(existing);
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logger";
|
||||
import * as utils from "../../utils";
|
||||
import { deepCompare, promiseTry } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
@@ -90,7 +90,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
|
||||
const requestBody = request.requestBody;
|
||||
|
||||
return utils.promiseTry(() => {
|
||||
return promiseTry(() => {
|
||||
// first see if we already have an entry for this request.
|
||||
const existing = this._getOutgoingRoomKeyRequest(requestBody);
|
||||
|
||||
@@ -138,7 +138,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
|
||||
for (const existing of this.outgoingRoomKeyRequests) {
|
||||
if (utils.deepCompare(existing.requestBody, requestBody)) {
|
||||
if (deepCompare(existing.requestBody, requestBody)) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -338,7 +338,7 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
deviceSessions = {};
|
||||
this.sessions[deviceKey] = deviceSessions;
|
||||
}
|
||||
deviceSessions[sessionId] = sessionInfo;
|
||||
safeSet(deviceSessions, sessionId, sessionInfo);
|
||||
}
|
||||
|
||||
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
|
||||
|
||||
@@ -25,9 +25,13 @@ export enum ServerSupport {
|
||||
export enum Feature {
|
||||
Thread = "Thread",
|
||||
ThreadUnreadNotifications = "ThreadUnreadNotifications",
|
||||
/**
|
||||
* @deprecated this is now exposed as a capability not a feature
|
||||
*/
|
||||
LoginTokenRequest = "LoginTokenRequest",
|
||||
RelationBasedRedactions = "RelationBasedRedactions",
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
RelationsRecursion = "RelationsRecursion",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -53,6 +57,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
[Feature.AccountDataDeletion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3391"],
|
||||
},
|
||||
[Feature.RelationsRecursion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3981"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import * as utils from "../utils";
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { Method } from "./method";
|
||||
import { ConnectionError, MatrixError } from "./errors";
|
||||
@@ -45,7 +45,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
public readonly opts: O,
|
||||
) {
|
||||
utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
opts.onlyData = !!opts.onlyData;
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* Sets the base URL for the identity server
|
||||
* @param url - The new base url
|
||||
*/
|
||||
public setIdBaseUrl(url: string): void {
|
||||
public setIdBaseUrl(url?: string): void {
|
||||
this.opts.idBaseUrl = url;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
|
||||
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
|
||||
if (queryParams) {
|
||||
utils.encodeParams(queryParams, url.searchParams);
|
||||
encodeParams(queryParams, url.searchParams);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
+13
-13
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { FetchHttpApi } from "./fetch";
|
||||
import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
|
||||
import { MediaPrefix } from "./prefix";
|
||||
import * as utils from "../utils";
|
||||
import { defer, QueryDict, removeElement } from "../utils";
|
||||
import * as callbacks from "../realtime-callbacks";
|
||||
import { Method } from "./method";
|
||||
import { ConnectionError } from "./errors";
|
||||
@@ -58,14 +58,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
total: 0,
|
||||
abortController,
|
||||
} as Upload;
|
||||
const defer = utils.defer<UploadResponse>();
|
||||
const deferred = defer<UploadResponse>();
|
||||
|
||||
if (global.XMLHttpRequest) {
|
||||
const xhr = new global.XMLHttpRequest();
|
||||
|
||||
const timeoutFn = function (): void {
|
||||
xhr.abort();
|
||||
defer.reject(new Error("Timeout"));
|
||||
deferred.reject(new Error("Timeout"));
|
||||
};
|
||||
|
||||
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
|
||||
@@ -84,16 +84,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
}
|
||||
|
||||
if (xhr.status >= 400) {
|
||||
defer.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
|
||||
} else {
|
||||
defer.resolve(JSON.parse(xhr.responseText));
|
||||
deferred.resolve(JSON.parse(xhr.responseText));
|
||||
}
|
||||
} catch (err) {
|
||||
if ((<Error>err).name === "AbortError") {
|
||||
defer.reject(err);
|
||||
deferred.reject(err);
|
||||
return;
|
||||
}
|
||||
defer.reject(new ConnectionError("request failed", <Error>err));
|
||||
deferred.reject(new ConnectionError("request failed", <Error>err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
xhr.abort();
|
||||
});
|
||||
} else {
|
||||
const queryParams: utils.QueryDict = {};
|
||||
const queryParams: QueryDict = {};
|
||||
if (includeFilename && fileName) {
|
||||
queryParams.filename = fileName;
|
||||
}
|
||||
@@ -146,16 +146,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
.then((response) => {
|
||||
return this.opts.onlyData ? <UploadResponse>response : response.json();
|
||||
})
|
||||
.then(defer.resolve, defer.reject);
|
||||
.then(deferred.resolve, deferred.reject);
|
||||
}
|
||||
|
||||
// remove the upload from the list on completion
|
||||
upload.promise = defer.promise.finally(() => {
|
||||
utils.removeElement(this.uploads, (elem) => elem === upload);
|
||||
upload.promise = deferred.promise.finally(() => {
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
});
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
utils.removeElement(this.uploads, (elem) => elem === upload);
|
||||
defer.reject(new DOMException("Aborted", "AbortError"));
|
||||
removeElement(this.uploads, (elem) => elem === upload);
|
||||
deferred.reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
this.uploads.push(upload);
|
||||
return upload.promise;
|
||||
|
||||
+26
-3
@@ -20,6 +20,7 @@ import { logger } from "./logger";
|
||||
import { MatrixClient } from "./client";
|
||||
import { defer, IDeferred } from "./utils";
|
||||
import { MatrixError } from "./http-api";
|
||||
import { UIAResponse } from "./@types/uia";
|
||||
|
||||
const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
@@ -44,7 +45,14 @@ export interface IStageStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data returned in the body of a 401 response from a UIA endpoint.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
|
||||
*/
|
||||
export interface IAuthData {
|
||||
// XXX: many of the fields here (`type`, `available_flows`, `required_stages`, etc) look like they
|
||||
// shouldn't be here. They aren't in the spec and it's unclear what they are supposed to do. Be wary of using them.
|
||||
session?: string;
|
||||
type?: string;
|
||||
completed?: string[];
|
||||
@@ -77,6 +85,11 @@ export enum AuthType {
|
||||
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameters which are submitted as the `auth` dict in a UIA request
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
export interface IAuthDict {
|
||||
// [key: string]: any;
|
||||
type?: string;
|
||||
@@ -97,7 +110,7 @@ export interface IAuthDict {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
class NoAuthFlowFoundError extends Error {
|
||||
export class NoAuthFlowFoundError extends Error {
|
||||
public name = "NoAuthFlowFoundError";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
@@ -106,6 +119,16 @@ class NoAuthFlowFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of an application callback to perform the user-interactive bit of UIA.
|
||||
*
|
||||
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
|
||||
* makes the HTTP request.
|
||||
*
|
||||
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
|
||||
*/
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
|
||||
interface IOpts {
|
||||
/**
|
||||
* A matrix client to use for the auth process
|
||||
@@ -139,7 +162,7 @@ interface IOpts {
|
||||
* The busyChanged callback should be used instead of the background flag.
|
||||
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
|
||||
*/
|
||||
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
|
||||
doRequest(auth: IAuthDict | null, background: boolean): Promise<IAuthData>;
|
||||
/**
|
||||
* Called when the status of the UI auth changes,
|
||||
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
|
||||
@@ -441,7 +464,7 @@ export class InteractiveAuth {
|
||||
* This can be set to true for requests that just poll to see if auth has
|
||||
* been completed elsewhere.
|
||||
*/
|
||||
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
|
||||
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
|
||||
try {
|
||||
const result = await this.requestCallback(auth, background);
|
||||
this.attemptAuthDeferred!.resolve(result);
|
||||
|
||||
+5
-1
@@ -38,6 +38,7 @@ export * from "./models/poll";
|
||||
export * from "./models/room-member";
|
||||
export * from "./models/room-state";
|
||||
export * from "./models/user";
|
||||
export * from "./models/device";
|
||||
export * from "./scheduler";
|
||||
export * from "./filter";
|
||||
export * from "./timeline-window";
|
||||
@@ -47,6 +48,7 @@ export * from "./store/memory";
|
||||
export * from "./store/indexeddb";
|
||||
export * from "./crypto/store/memory-crypto-store";
|
||||
export * from "./crypto/store/indexeddb-crypto-store";
|
||||
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
|
||||
export * from "./content-repo";
|
||||
export * from "./@types/event";
|
||||
export * from "./@types/PushRules";
|
||||
@@ -62,13 +64,15 @@ export type { MatrixCall } from "./webrtc/call";
|
||||
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
|
||||
export type { GroupCall } from "./webrtc/groupCall";
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
export { DeviceVerificationStatus } from "./crypto-api";
|
||||
export { CryptoEvent } from "./crypto";
|
||||
|
||||
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
|
||||
|
||||
/**
|
||||
* Configure a different factory to be used for creating crypto stores
|
||||
*
|
||||
* @param fac - a function which will return a new {@link CryptoStore}
|
||||
* @param fac - a function which will return a new `CryptoStore`
|
||||
*/
|
||||
export function setCryptoStoreFactory(fac: () => CryptoStore): void {
|
||||
cryptoStoreFactory = fac;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/** State of the verification of the device. */
|
||||
export enum DeviceVerification {
|
||||
Blocked = -1,
|
||||
Unverified = 0,
|
||||
Verified = 1,
|
||||
}
|
||||
|
||||
/** A map from user ID to device ID to Device */
|
||||
export type DeviceMap = Map<string, Map<string, Device>>;
|
||||
|
||||
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
|
||||
|
||||
/**
|
||||
* Information on a user's device, as returned by {@link CryptoApi.getUserDeviceInfo}.
|
||||
*/
|
||||
export class Device {
|
||||
/** id of the device */
|
||||
public readonly deviceId: string;
|
||||
|
||||
/** id of the user that owns the device */
|
||||
public readonly userId: string;
|
||||
|
||||
/** list of algorithms supported by this device */
|
||||
public readonly algorithms: string[];
|
||||
|
||||
/** a map from `<key type>:<id> -> <base64-encoded key>` */
|
||||
public readonly keys: Map<string, string>;
|
||||
|
||||
/** whether the device has been verified/blocked by the user */
|
||||
public readonly verified: DeviceVerification;
|
||||
|
||||
/** a map `<userId, map<algorithm:device_id, signature>>` */
|
||||
public readonly signatures: Map<string, Map<string, string>>;
|
||||
|
||||
/** display name of the device */
|
||||
public readonly displayName?: string;
|
||||
|
||||
public constructor(opts: DeviceParameters) {
|
||||
this.deviceId = opts.deviceId;
|
||||
this.userId = opts.userId;
|
||||
this.algorithms = opts.algorithms;
|
||||
this.keys = opts.keys;
|
||||
this.verified = opts.verified || DeviceVerification.Unverified;
|
||||
this.signatures = opts.signatures || new Map();
|
||||
this.displayName = opts.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fingerprint for this device (ie, the Ed25519 key)
|
||||
*
|
||||
* @returns base64-encoded fingerprint of this device
|
||||
*/
|
||||
public getFingerprint(): string | undefined {
|
||||
return this.keys.get(`ed25519:${this.deviceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identity key for this device (ie, the Curve25519 key)
|
||||
*
|
||||
* @returns base64-encoded identity key of this device
|
||||
*/
|
||||
public getIdentityKey(): string | undefined {
|
||||
return this.keys.get(`curve25519:${this.deviceId}`);
|
||||
}
|
||||
}
|
||||
+37
-4
@@ -37,6 +37,7 @@ import { EventStatus } from "./event-status";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
import { CryptoBackend } from "../common-crypto/CryptoBackend";
|
||||
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
|
||||
import { IAnnotatedPushRule } from "../@types/PushRules";
|
||||
|
||||
export { EventStatus } from "./event-status";
|
||||
|
||||
@@ -121,6 +122,11 @@ export interface IMentions {
|
||||
room?: boolean;
|
||||
}
|
||||
|
||||
export interface PushDetails {
|
||||
rule?: IAnnotatedPushRule;
|
||||
actions?: IActionsObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an event is a visibility change event, as per MSC3531,
|
||||
* the visibility change implied by the event.
|
||||
@@ -220,7 +226,8 @@ export type MatrixEventHandlerMap = {
|
||||
} & Pick<ThreadEventHandlerMap, ThreadEvent.Update>;
|
||||
|
||||
export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap> {
|
||||
private pushActions: IActionsObject | null = null;
|
||||
// applied push rule and action for this event
|
||||
private pushDetails: PushDetails = {};
|
||||
private _replacingEvent: MatrixEvent | null = null;
|
||||
private _localRedactionEvent: MatrixEvent | null = null;
|
||||
private _isCancelled = false;
|
||||
@@ -888,7 +895,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// highlighting when the user's name is mentioned rely on this happening. We also want
|
||||
// to set the push actions before emitting so that any notification listeners don't
|
||||
// pick up the wrong contents.
|
||||
this.setPushActions(null);
|
||||
this.setPushDetails();
|
||||
|
||||
if (options.emit !== false) {
|
||||
this.emit(MatrixEventEvent.Decrypted, this, err);
|
||||
@@ -1241,16 +1248,42 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @returns push actions
|
||||
*/
|
||||
public getPushActions(): IActionsObject | null {
|
||||
return this.pushActions;
|
||||
return this.pushDetails.actions || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push details, if known, for this event
|
||||
*
|
||||
* @returns push actions
|
||||
*/
|
||||
public getPushDetails(): PushDetails {
|
||||
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.pushActions = pushActions;
|
||||
this.pushDetails = {
|
||||
actions: pushActions || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the push details for this event.
|
||||
*
|
||||
* @param pushActions - push actions
|
||||
* @param rule - the executed push rule
|
||||
*/
|
||||
public setPushDetails(pushActions?: IActionsObject, rule?: IAnnotatedPushRule): void {
|
||||
this.pushDetails = {
|
||||
actions: pushActions,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { M_POLL_START } from "matrix-events-sdk";
|
||||
|
||||
import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
|
||||
import { MatrixClient } from "../client";
|
||||
import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
|
||||
@@ -266,3 +268,14 @@ export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, P
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether the event is a start, response or end poll event.
|
||||
*
|
||||
* @param event - Event to test
|
||||
* @returns true if the event is a poll event, else false
|
||||
*/
|
||||
export const isPollEvent = (event: MatrixEvent): boolean => {
|
||||
const eventType = event.getType();
|
||||
return M_POLL_START.matches(eventType) || M_POLL_RESPONSE.matches(eventType) || M_POLL_END.matches(eventType);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
WrappedReceipt,
|
||||
} from "../@types/read_receipts";
|
||||
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
|
||||
import * as utils from "../utils";
|
||||
import { isSupportedReceiptType } from "../utils";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { EventType } from "../@types/event";
|
||||
import { EventTimelineSet } from "./event-timeline-set";
|
||||
@@ -267,7 +267,7 @@ export abstract class ReadReceipt<
|
||||
public getUsersReadUpTo(event: MatrixEvent): string[] {
|
||||
return this.getReceiptsForEvent(event)
|
||||
.filter(function (receipt) {
|
||||
return utils.isSupportedReceiptType(receipt.type);
|
||||
return isSupportedReceiptType(receipt.type);
|
||||
})
|
||||
.map(function (receipt) {
|
||||
return receipt.userId;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { getHttpUriForMxc } from "../content-repo";
|
||||
import * as utils from "../utils";
|
||||
import { removeDirectionOverrideChars, removeHiddenChars } from "../utils";
|
||||
import { User } from "./user";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { RoomState } from "./room-state";
|
||||
@@ -206,8 +206,8 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
|
||||
// not quite raw: we strip direction override chars so it can safely be inserted into
|
||||
// blocks of text without breaking the text direction
|
||||
this.rawDisplayName = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
|
||||
if (!this.rawDisplayName || !utils.removeHiddenChars(this.rawDisplayName)) {
|
||||
this.rawDisplayName = removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
|
||||
if (!this.rawDisplayName || !removeHiddenChars(this.rawDisplayName)) {
|
||||
this.rawDisplayName = this.userId;
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) return false;
|
||||
if (!removeHiddenChars(displayName)) return false;
|
||||
|
||||
if (!roomState) return false;
|
||||
|
||||
@@ -432,11 +432,11 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
|
||||
function calculateDisplayName(selfUserId: string, displayName: string | undefined, disambiguate: boolean): string {
|
||||
if (!displayName || displayName === selfUserId) return selfUserId;
|
||||
|
||||
if (disambiguate) return utils.removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
|
||||
if (disambiguate) return removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) return selfUserId;
|
||||
if (!removeHiddenChars(displayName)) return selfUserId;
|
||||
|
||||
// We always strip the direction override characters (LRO and RLO).
|
||||
// These override the text direction for all subsequent characters
|
||||
@@ -449,5 +449,5 @@ function calculateDisplayName(selfUserId: string, displayName: string | undefine
|
||||
// names should flip into the correct direction automatically based on
|
||||
// the characters, and you can still embed rtl in ltr or vice versa
|
||||
// with the embed chars or marker chars.
|
||||
return utils.removeDirectionOverrideChars(displayName);
|
||||
return removeDirectionOverrideChars(displayName);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { RoomMember } from "./room-member";
|
||||
import { logger } from "../logger";
|
||||
import * as utils from "../utils";
|
||||
import { isNumber, removeHiddenChars } from "../utils";
|
||||
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
|
||||
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { MatrixClient } from "../client";
|
||||
@@ -759,7 +759,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
* @returns An array of user IDs or an empty array.
|
||||
*/
|
||||
public getUserIdsWithDisplayName(displayName: string): string[] {
|
||||
return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
|
||||
return this.displayNameToUserIds.get(removeHiddenChars(displayName)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -798,7 +798,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
}
|
||||
|
||||
let requiredLevel = 50;
|
||||
if (utils.isNumber(powerLevels[action])) {
|
||||
if (isNumber(powerLevels[action])) {
|
||||
requiredLevel = powerLevels[action]!;
|
||||
}
|
||||
|
||||
@@ -928,7 +928,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
powerLevelsEvent &&
|
||||
powerLevelsEvent.getContent() &&
|
||||
powerLevelsEvent.getContent().notifications &&
|
||||
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
|
||||
isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
|
||||
) {
|
||||
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
|
||||
}
|
||||
@@ -1058,7 +1058,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
// We clobber the user_id > name lookup but the name -> [user_id] lookup
|
||||
// means we need to remove that user ID from that array rather than nuking
|
||||
// the lot.
|
||||
const strippedOldName = utils.removeHiddenChars(oldName);
|
||||
const strippedOldName = removeHiddenChars(oldName);
|
||||
|
||||
const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
|
||||
if (existingUserIds) {
|
||||
@@ -1070,7 +1070,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
|
||||
this.userIdsToDisplayNames[userId] = displayName;
|
||||
|
||||
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
|
||||
const strippedDisplayname = displayName && removeHiddenChars(displayName);
|
||||
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
|
||||
if (strippedDisplayname) {
|
||||
const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
|
||||
|
||||
+127
-34
@@ -24,7 +24,7 @@ import {
|
||||
} from "./event-timeline-set";
|
||||
import { Direction, EventTimeline } from "./event-timeline";
|
||||
import { getHttpUriForMxc } from "../content-repo";
|
||||
import * as utils from "../utils";
|
||||
import { compare, removeElement } from "../utils";
|
||||
import { normalize, noUnsafeEventProps } from "../utils";
|
||||
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
|
||||
import { EventStatus } from "./event-status";
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
|
||||
import { Poll, PollEvent } from "./poll";
|
||||
import { isPollEvent, Poll, PollEvent } from "./poll";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -324,7 +324,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
private unthreadedReceipts = new Map<string, Receipt>();
|
||||
private readonly timelineSets: EventTimelineSet[];
|
||||
public readonly polls: Map<string, Poll> = new Map<string, Poll>();
|
||||
public readonly threadsTimelineSets: EventTimelineSet[] = [];
|
||||
|
||||
/**
|
||||
* Empty array if the timeline sets have not been initialised. After initialisation:
|
||||
* 0: All threads
|
||||
* 1: Threads the current user has participated in
|
||||
*/
|
||||
public readonly threadsTimelineSets: [] | [EventTimelineSet, EventTimelineSet] = [];
|
||||
|
||||
// any filtered timeline sets we're maintaining for this room
|
||||
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
|
||||
private timelineNeedsRefresh = false;
|
||||
@@ -361,22 +368,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* The room summary.
|
||||
*/
|
||||
public summary: RoomSummary | null = null;
|
||||
// legacy fields
|
||||
/**
|
||||
* The live event timeline for this room, with the oldest event at index 0.
|
||||
* Present for backwards compatibility - prefer getLiveTimeline().getEvents()
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getEvents() instead
|
||||
*/
|
||||
public timeline!: MatrixEvent[];
|
||||
/**
|
||||
* oldState The state of the room at the time of the oldest
|
||||
* event in the live timeline. Present for backwards compatibility -
|
||||
* prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
|
||||
* oldState The state of the room at the time of the oldest event in the live timeline.
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead
|
||||
*/
|
||||
public oldState!: RoomState;
|
||||
/**
|
||||
* currentState The state of the room at the time of the
|
||||
* newest event in the timeline. Present for backwards compatibility -
|
||||
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
|
||||
* currentState The state of the room at the time of the newest event in the timeline.
|
||||
*
|
||||
* @deprecated Present for backwards compatibility.
|
||||
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
|
||||
*/
|
||||
public currentState!: RoomState;
|
||||
public readonly relations = new RelationsContainer(this.client, this);
|
||||
@@ -386,6 +396,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* This is not a comprehensive list of the threads that exist in this room
|
||||
*/
|
||||
private threads = new Map<string, Thread>();
|
||||
|
||||
/**
|
||||
* @deprecated This value is unreliable. It may not contain the last thread.
|
||||
* Use {@link Room.getLastThread} instead.
|
||||
*/
|
||||
public lastThread?: Thread;
|
||||
|
||||
/**
|
||||
@@ -490,7 +505,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.createThreadTimelineSet(ThreadFilterType.My),
|
||||
]);
|
||||
const timelineSets = await this.threadTimelineSetsPromise;
|
||||
this.threadsTimelineSets.push(...timelineSets);
|
||||
this.threadsTimelineSets[0] = timelineSets[0];
|
||||
this.threadsTimelineSets[1] = timelineSets[1];
|
||||
return timelineSets;
|
||||
} catch (e) {
|
||||
this.threadTimelineSetsPromise = null;
|
||||
@@ -717,7 +733,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
);
|
||||
}
|
||||
|
||||
const removed = utils.removeElement(
|
||||
const removed = removeElement(
|
||||
this.pendingEventList,
|
||||
function (ev) {
|
||||
return ev.getId() == eventId;
|
||||
@@ -774,6 +790,54 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last live event of this room.
|
||||
* "last" means latest timestamp.
|
||||
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
|
||||
* Unfortunately, this information is currently not available in the client.
|
||||
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
|
||||
* "live of this room" means from all live timelines: the room and the threads.
|
||||
*
|
||||
* @returns MatrixEvent if there is a last event; else undefined.
|
||||
*/
|
||||
public getLastLiveEvent(): MatrixEvent | undefined {
|
||||
const roomEvents = this.getLiveTimeline().getEvents();
|
||||
const lastRoomEvent = roomEvents[roomEvents.length - 1] as MatrixEvent | undefined;
|
||||
const lastThread = this.getLastThread();
|
||||
|
||||
if (!lastThread) return lastRoomEvent;
|
||||
|
||||
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
|
||||
|
||||
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last thread of this room.
|
||||
* "last" means latest timestamp of the last thread event.
|
||||
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
|
||||
* Unfortunately, this information is currently not available in the client.
|
||||
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
|
||||
*
|
||||
* @returns the thread with the most recent event in its live time line. undefined if there is no thread.
|
||||
*/
|
||||
public getLastThread(): Thread | undefined {
|
||||
return this.getThreads().reduce<Thread | undefined>((lastThread: Thread | undefined, thread: Thread) => {
|
||||
if (!lastThread) return thread;
|
||||
|
||||
const threadEvent = thread.events[thread.events.length - 1];
|
||||
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
|
||||
|
||||
if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) {
|
||||
// Last message of current thread is newer → new last thread.
|
||||
// Equal also means newer, because it was added to the thread map later.
|
||||
return thread;
|
||||
}
|
||||
|
||||
return lastThread;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the membership type (join | leave | invite) for the logged in user
|
||||
*/
|
||||
@@ -1897,35 +1961,62 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.threadsReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@link processPollEvent} for a list of events.
|
||||
*
|
||||
* @param events - List of events
|
||||
*/
|
||||
public async processPollEvents(events: MatrixEvent[]): Promise<void> {
|
||||
const processPollStartEvent = (event: MatrixEvent): void => {
|
||||
if (!M_POLL_START.matches(event.getType())) return;
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Continue if the event is a clear text, non-poll event.
|
||||
if (!event.isEncrypted() && !isPollEvent(event)) continue;
|
||||
|
||||
/**
|
||||
* Try to decrypt the event. Promise resolution does not guarantee a successful decryption.
|
||||
* Retry is handled in {@link processPollEvent}.
|
||||
*/
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
this.processPollEvent(event);
|
||||
} catch (err) {
|
||||
logger.warn("Error processing poll event", event.getId(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes poll events:
|
||||
* If the event has a decryption failure, it will listen for decryption and tries again.
|
||||
* If it is a poll start event (`m.poll.start`),
|
||||
* it creates and stores a Poll model and emits a PollEvent.New event.
|
||||
* If the event is related to a poll, it will add it to the poll.
|
||||
* Noop for other cases.
|
||||
*
|
||||
* @param event - Event that could be a poll event
|
||||
*/
|
||||
private async processPollEvent(event: MatrixEvent): Promise<void> {
|
||||
if (event.isDecryptionFailure()) {
|
||||
event.once(MatrixEventEvent.Decrypted, (maybeDecryptedEvent: MatrixEvent) => {
|
||||
this.processPollEvent(maybeDecryptedEvent);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (M_POLL_START.matches(event.getType())) {
|
||||
try {
|
||||
const poll = new Poll(event, this.client, this);
|
||||
this.polls.set(event.getId()!, poll);
|
||||
this.emit(PollEvent.New, poll);
|
||||
} catch {}
|
||||
// poll creation can fail for malformed poll start events
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const processPollRelationEvent = (event: MatrixEvent): void => {
|
||||
const relationEventId = event.relationEventId;
|
||||
if (relationEventId && this.polls.has(relationEventId)) {
|
||||
const poll = this.polls.get(relationEventId);
|
||||
poll?.onNewRelation(event);
|
||||
}
|
||||
};
|
||||
const relationEventId = event.relationEventId;
|
||||
|
||||
const processPollEvent = (event: MatrixEvent): void => {
|
||||
processPollStartEvent(event);
|
||||
processPollRelationEvent(event);
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
processPollEvent(event);
|
||||
} catch {}
|
||||
if (relationEventId && this.polls.has(relationEventId)) {
|
||||
const poll = this.polls.get(relationEventId);
|
||||
poll?.onNewRelation(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1934,6 +2025,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @internal
|
||||
*/
|
||||
private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
|
||||
if (this.threadsTimelineSets.length === 0) return;
|
||||
|
||||
const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
|
||||
|
||||
const { chunk: events, end } = await this.client.createThreadListMessagesRequest(
|
||||
@@ -3174,7 +3267,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return true;
|
||||
});
|
||||
// make sure members have stable order
|
||||
otherMembers.sort((a, b) => utils.compare(a.userId, b.userId));
|
||||
otherMembers.sort((a, b) => compare(a.userId, b.userId));
|
||||
// only 5 first members, immitate summaryHeroes
|
||||
otherMembers = otherMembers.slice(0, 5);
|
||||
otherNames = otherMembers.map((m) => m.name);
|
||||
|
||||
+23
-19
@@ -28,6 +28,7 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue";
|
||||
import { logger } from "../logger";
|
||||
import { ReadReceipt } from "./read-receipt";
|
||||
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
|
||||
import { Feature, ServerSupport } from "../feature";
|
||||
|
||||
export enum ThreadEvent {
|
||||
New = "Thread.new",
|
||||
@@ -458,25 +459,28 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
|
||||
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
|
||||
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
|
||||
return Promise.all(
|
||||
events
|
||||
.filter((e) => e.isEncrypted())
|
||||
.map((event: MatrixEvent) => {
|
||||
if (event.isRelation()) return; // skip - relations don't get edits
|
||||
return this.client
|
||||
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
|
||||
limit: 1,
|
||||
})
|
||||
.then((relations) => {
|
||||
if (relations.events.length) {
|
||||
event.makeReplaced(relations.events[0]);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
});
|
||||
}),
|
||||
);
|
||||
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
if (recursionSupport !== ServerSupport.Unsupported) {
|
||||
return Promise.all(
|
||||
events
|
||||
.filter((e) => e.isEncrypted())
|
||||
.map((event: MatrixEvent) => {
|
||||
if (event.isRelation()) return; // skip - relations don't get edits
|
||||
return this.client
|
||||
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
|
||||
limit: 1,
|
||||
})
|
||||
.then((relations) => {
|
||||
if (relations.events.length) {
|
||||
event.makeReplaced(relations.events[0]);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public setEventMetadata(event: Optional<MatrixEvent>): void {
|
||||
|
||||
@@ -59,10 +59,6 @@ export class TypedEventEmitter<
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
public eventNames(): (Events | EventEmitterEvents)[] {
|
||||
return super.eventNames() as Array<Events | EventEmitterEvents>;
|
||||
}
|
||||
|
||||
public listenerCount(event: Events | EventEmitterEvents): number {
|
||||
return super.listenerCount(event);
|
||||
}
|
||||
|
||||
+20
-5
@@ -589,7 +589,7 @@ export class PushProcessor {
|
||||
* @internal
|
||||
*/
|
||||
public static partsForDottedKey(str: string): string[] {
|
||||
const result = [];
|
||||
const result: string[] = [];
|
||||
|
||||
// The current field and whether the previous character was the escape
|
||||
// character (a backslash).
|
||||
@@ -688,17 +688,24 @@ export class PushProcessor {
|
||||
if (!rulesets) {
|
||||
return null;
|
||||
}
|
||||
if (ev.getSender() === this.client.credentials.userId) {
|
||||
|
||||
if (ev.getSender() === this.client.getSafeUserId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.matchingRuleFromKindSet(ev, rulesets.global);
|
||||
}
|
||||
|
||||
private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets?: IPushRules): IActionsObject {
|
||||
private pushActionsForEventAndRulesets(
|
||||
ev: MatrixEvent,
|
||||
rulesets?: IPushRules,
|
||||
): {
|
||||
actions?: IActionsObject;
|
||||
rule?: IAnnotatedPushRule;
|
||||
} {
|
||||
const rule = this.matchingRuleForEventWithRulesets(ev, rulesets);
|
||||
if (!rule) {
|
||||
return {} as IActionsObject;
|
||||
return {};
|
||||
}
|
||||
|
||||
const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
|
||||
@@ -710,7 +717,7 @@ export class PushProcessor {
|
||||
actionObj.tweaks.highlight = rule.kind == PushRuleKind.ContentSpecific;
|
||||
}
|
||||
|
||||
return actionObj;
|
||||
return { actions: actionObj, rule };
|
||||
}
|
||||
|
||||
public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean {
|
||||
@@ -732,6 +739,14 @@ export class PushProcessor {
|
||||
* Get the user's push actions for the given event
|
||||
*/
|
||||
public actionsForEvent(ev: MatrixEvent): IActionsObject {
|
||||
const { actions } = this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
|
||||
return actions || ({} as IActionsObject);
|
||||
}
|
||||
|
||||
public actionsAndRuleForEvent(ev: MatrixEvent): {
|
||||
actions?: IActionsObject;
|
||||
rule?: IAnnotatedPushRule;
|
||||
} {
|
||||
return this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IMinimalEvent } from "./sync-accumulator";
|
||||
import { EventType } from "./@types/event";
|
||||
import { isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils";
|
||||
import { IContent } from "./models/event";
|
||||
import { ReceiptContent, ReceiptType } from "./@types/read_receipts";
|
||||
|
||||
interface AccumulatedReceipt {
|
||||
data: IMinimalEvent;
|
||||
type: ReceiptType;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarises the read receipts within a room. Used by the sync accumulator.
|
||||
*
|
||||
* Given receipts for users, picks the most recently-received one and provides
|
||||
* the results in a new fake receipt event returned from
|
||||
* buildAccumulatedReceiptEvent().
|
||||
*
|
||||
* Handles unthreaded receipts and receipts in each thread separately, so the
|
||||
* returned event contains the most recently received unthreaded receipt, and
|
||||
* the most recently received receipt in each thread.
|
||||
*/
|
||||
export class ReceiptAccumulator {
|
||||
/** user_id -\> most-recently-received unthreaded receipt */
|
||||
private unthreadedReadReceipts: Map<string, AccumulatedReceipt> = new Map();
|
||||
|
||||
/** thread_id -\> user_id -\> most-recently-received receipt for this thread */
|
||||
private threadedReadReceipts: MapWithDefault<string, Map<string, AccumulatedReceipt>> = new MapWithDefault(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Provide an unthreaded receipt for this user. Overwrites any other
|
||||
* unthreaded receipt we have for this user.
|
||||
*/
|
||||
private setUnthreaded(userId: string, receipt: AccumulatedReceipt): void {
|
||||
this.unthreadedReadReceipts.set(userId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a receipt for this user in this thread. Overwrites any other
|
||||
* receipt we have for this user in this thread.
|
||||
*/
|
||||
private setThreaded(threadId: string, userId: string, receipt: AccumulatedReceipt): void {
|
||||
this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
|
||||
* most recently-received unthreaded receipts for each user.
|
||||
*/
|
||||
private allUnthreaded(): IterableIterator<[string, AccumulatedReceipt]> {
|
||||
return this.unthreadedReadReceipts.entries();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
|
||||
* most recently-received threaded receipts for each user, in all
|
||||
* threads.
|
||||
*/
|
||||
private *allThreaded(): IterableIterator<[string, AccumulatedReceipt]> {
|
||||
for (const receiptsForThread of this.threadedReadReceipts.values()) {
|
||||
for (const e of receiptsForThread.entries()) {
|
||||
yield e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of ephemeral events, find the receipts and store the
|
||||
* relevant ones to be returned later from buildAccumulatedReceiptEvent().
|
||||
*/
|
||||
public consumeEphemeralEvents(events: IMinimalEvent[] | undefined): void {
|
||||
events?.forEach((e) => {
|
||||
if (e.type !== EventType.Receipt || !e.content) {
|
||||
// This means we'll drop unknown ephemeral events but that
|
||||
// seems okay.
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle m.receipt events. They clobber based on:
|
||||
// (user_id, receipt_type)
|
||||
// but they are keyed in the event as:
|
||||
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
|
||||
// so store them in the former so we can accumulate receipt deltas
|
||||
// quickly and efficiently (we expect a lot of them). Fold the
|
||||
// receipt type into the key name since we only have 1 at the
|
||||
// moment (m.read) and nested JSON objects are slower and more
|
||||
// of a hassle to work with. We'll inflate this back out when
|
||||
// getJSON() is called.
|
||||
Object.keys(e.content).forEach((eventId) => {
|
||||
Object.entries<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => {
|
||||
if (!isSupportedReceiptType(key)) return;
|
||||
|
||||
for (const userId of Object.keys(value)) {
|
||||
const data = e.content[eventId][key][userId];
|
||||
|
||||
const receipt = {
|
||||
data: e.content[eventId][key][userId],
|
||||
type: key as ReceiptType,
|
||||
eventId,
|
||||
};
|
||||
|
||||
if (!data.thread_id) {
|
||||
this.setUnthreaded(userId, receipt);
|
||||
} else {
|
||||
this.setThreaded(data.thread_id, userId, receipt);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a receipt event that contains all relevant information for this
|
||||
* room, taking the most recently received receipt for each user in an
|
||||
* unthreaded context, and in each thread.
|
||||
*/
|
||||
public buildAccumulatedReceiptEvent(roomId: string): IMinimalEvent | null {
|
||||
const receiptEvent: IMinimalEvent = {
|
||||
type: EventType.Receipt,
|
||||
room_id: roomId,
|
||||
content: {
|
||||
// $event_id: { "m.read": { $user_id: $json } }
|
||||
} as IContent,
|
||||
};
|
||||
|
||||
const receiptEventContent: MapWithDefault<
|
||||
string,
|
||||
MapWithDefault<ReceiptType, Map<string, object>>
|
||||
> = new MapWithDefault(() => new MapWithDefault(() => new Map()));
|
||||
|
||||
for (const [userId, receiptData] of this.allUnthreaded()) {
|
||||
receiptEventContent
|
||||
.getOrCreate(receiptData.eventId)
|
||||
.getOrCreate(receiptData.type)
|
||||
.set(userId, receiptData.data);
|
||||
}
|
||||
|
||||
for (const [userId, receiptData] of this.allThreaded()) {
|
||||
receiptEventContent
|
||||
.getOrCreate(receiptData.eventId)
|
||||
.getOrCreate(receiptData.type)
|
||||
.set(userId, receiptData.data);
|
||||
}
|
||||
|
||||
receiptEvent.content = recursiveMapToObject(receiptEventContent);
|
||||
|
||||
return receiptEventContent.size > 0 ? receiptEvent : null;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { UnstableValue } from "matrix-events-sdk";
|
||||
|
||||
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
|
||||
import { MatrixClient } from "../client";
|
||||
import { IMSC3882GetLoginTokenCapability, MatrixClient, UNSTABLE_MSC3882_CAPABILITY } from "../client";
|
||||
import { CrossSigningInfo } from "../crypto/CrossSigning";
|
||||
import { DeviceInfo } from "../crypto/deviceinfo";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
||||
@@ -100,9 +100,14 @@ export class MSC3906Rendezvous {
|
||||
|
||||
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
|
||||
|
||||
// in r1 of MSC3882 the availability is exposed as a capability
|
||||
const capabilities = await this.client.getCapabilities();
|
||||
// in r0 of MSC3882 the availability is exposed as a feature flag
|
||||
const features = await buildFeatureSupportMap(await this.client.getVersions());
|
||||
const capability = UNSTABLE_MSC3882_CAPABILITY.findIn<IMSC3882GetLoginTokenCapability>(capabilities);
|
||||
|
||||
// determine available protocols
|
||||
if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
|
||||
if (!capability?.enabled && features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
|
||||
logger.info("Server doesn't support MSC3882");
|
||||
await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported });
|
||||
await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
|
||||
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
SigningKeysUploadRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
@@ -53,7 +56,7 @@ export class OutgoingRequestProcessor {
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
|
||||
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
|
||||
let resp: string;
|
||||
|
||||
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
|
||||
@@ -79,6 +82,14 @@ export class OutgoingRequestProcessor {
|
||||
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
|
||||
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else if (msg instanceof SigningKeysUploadRequest) {
|
||||
resp = await this.makeRequestWithUIA(
|
||||
Method.Post,
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
{},
|
||||
msg.body,
|
||||
uiaCallback,
|
||||
);
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
@@ -89,6 +100,31 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequestWithUIA<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body: string,
|
||||
uiaCallback: UIAuthCallback<T> | undefined,
|
||||
): Promise<string> {
|
||||
if (!uiaCallback) {
|
||||
return await this.rawJsonRequest(method, path, queryParams, body);
|
||||
}
|
||||
|
||||
const parsedBody = JSON.parse(body);
|
||||
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
|
||||
const newBody = {
|
||||
...parsedBody,
|
||||
auth,
|
||||
};
|
||||
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
|
||||
return JSON.parse(resp) as T;
|
||||
};
|
||||
|
||||
const resp = await uiaCallback(makeRequest);
|
||||
return JSON.stringify(resp);
|
||||
}
|
||||
|
||||
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
|
||||
const opts = {
|
||||
// inhibit the JSON stringification and parsing within HttpApi.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { Device, DeviceVerification } from "../models/device";
|
||||
import { DeviceKeys } from "../client";
|
||||
|
||||
/**
|
||||
* Convert a {@link RustSdkCryptoJs.Device} to a {@link Device}
|
||||
* @param device - Rust Sdk device
|
||||
* @param userId - owner of the device
|
||||
*/
|
||||
export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: RustSdkCryptoJs.UserId): Device {
|
||||
// Copy rust device keys to Device.keys
|
||||
const keys = new Map<string, string>();
|
||||
for (const [keyId, key] of device.keys.entries()) {
|
||||
keys.set(keyId.toString(), key.toBase64());
|
||||
}
|
||||
|
||||
// Compute verified from device state
|
||||
let verified: DeviceVerification = DeviceVerification.Unverified;
|
||||
if (device.isBlacklisted()) {
|
||||
verified = DeviceVerification.Blocked;
|
||||
} else if (device.isVerified()) {
|
||||
verified = DeviceVerification.Verified;
|
||||
}
|
||||
|
||||
// Convert rust signatures to Device.signatures
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
const mayBeSignatureMap: Map<string, RustSdkCryptoJs.MaybeSignature> | undefined = device.signatures.get(userId);
|
||||
if (mayBeSignatureMap) {
|
||||
const convertedSignatures = new Map<string, string>();
|
||||
// Convert maybeSignatures map to a Map<string, string>
|
||||
for (const [key, value] of mayBeSignatureMap.entries()) {
|
||||
if (value.isValid() && value.signature) {
|
||||
convertedSignatures.set(key, value.signature.toBase64());
|
||||
}
|
||||
}
|
||||
|
||||
signatures.set(userId.toString(), convertedSignatures);
|
||||
}
|
||||
|
||||
// Convert rust algorithms to algorithms
|
||||
const rustAlgorithms: RustSdkCryptoJs.EncryptionAlgorithm[] = device.algorithms;
|
||||
// Use set to ensure that algorithms are not duplicated
|
||||
const algorithms = new Set<string>();
|
||||
rustAlgorithms.forEach((algorithm) => {
|
||||
switch (algorithm) {
|
||||
case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2:
|
||||
algorithms.add("m.megolm.v1.aes-sha2");
|
||||
break;
|
||||
case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2:
|
||||
default:
|
||||
algorithms.add("m.olm.v1.curve25519-aes-sha2");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return new Device({
|
||||
deviceId: device.deviceId.toString(),
|
||||
userId: userId.toString(),
|
||||
keys,
|
||||
algorithms: Array.from(algorithms),
|
||||
verified,
|
||||
signatures,
|
||||
displayName: device.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>`
|
||||
* @param deviceKeys - Device keys object to convert
|
||||
*/
|
||||
export function deviceKeysToDeviceMap(deviceKeys: DeviceKeys): Map<string, Device> {
|
||||
return new Map(
|
||||
Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]),
|
||||
);
|
||||
}
|
||||
|
||||
// Device from `/keys/query` request
|
||||
type QueryDevice = DeviceKeys[keyof DeviceKeys];
|
||||
|
||||
/**
|
||||
* Convert `/keys/query` {@link QueryDevice} device to {@link Device}
|
||||
* @param device - Device from `/keys/query` request
|
||||
*/
|
||||
export function downloadDeviceToJsDevice(device: QueryDevice): Device {
|
||||
const keys = new Map(Object.entries(device.keys));
|
||||
const displayName = device.unsigned?.device_display_name;
|
||||
|
||||
const signatures = new Map<string, Map<string, string>>();
|
||||
if (device.signatures) {
|
||||
for (const userId in device.signatures) {
|
||||
signatures.set(userId, new Map(Object.entries(device.signatures[userId])));
|
||||
}
|
||||
}
|
||||
|
||||
return new Device({
|
||||
deviceId: device.device_id,
|
||||
userId: device.user_id,
|
||||
keys,
|
||||
algorithms: device.algorithms,
|
||||
verified: DeviceVerification.Unverified,
|
||||
signatures,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
@@ -20,11 +20,22 @@ import { RustCrypto } from "./rust-crypto";
|
||||
import { logger } from "../logger";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./constants";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
|
||||
/**
|
||||
* Create a new `RustCrypto` implementation
|
||||
*
|
||||
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
* @param userId - The local user's User ID.
|
||||
* @param deviceId - The local user's Device ID.
|
||||
* @param secretStorage - Interface to server-side secret storage.
|
||||
*/
|
||||
export async function initRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
): Promise<RustCrypto> {
|
||||
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
@@ -38,7 +49,10 @@ export async function initRustCrypto(
|
||||
|
||||
// TODO: use the pickle key for the passphrase
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
);
|
||||
|
||||
logger.info("Completed rust crypto-sdk setup");
|
||||
return rustCrypto;
|
||||
|
||||
+360
-35
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -17,24 +17,32 @@ limitations under the License.
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
import type { IToDeviceEvent } from "../sync-accumulator";
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import type { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
|
||||
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningKey } from "../crypto/api";
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
*/
|
||||
export class RustCrypto implements CryptoBackend {
|
||||
public globalErrorOnUnknownDevices = false;
|
||||
private _trustCrossSignedDevices = true;
|
||||
|
||||
/** whether {@link stop} has been called */
|
||||
private stopped = false;
|
||||
@@ -45,17 +53,33 @@ export class RustCrypto implements CryptoBackend {
|
||||
/** mapping of roomId → encryptor class */
|
||||
private roomEncryptors: Record<string, RoomEncryptor> = {};
|
||||
|
||||
private eventDecryptor: EventDecryptor;
|
||||
private keyClaimManager: KeyClaimManager;
|
||||
private outgoingRequestProcessor: OutgoingRequestProcessor;
|
||||
|
||||
public constructor(
|
||||
/** The `OlmMachine` from the underlying rust crypto sdk. */
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
|
||||
/**
|
||||
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
*
|
||||
* We expect it to set the access token, etc.
|
||||
*/
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
|
||||
/** The local user's User ID. */
|
||||
_userId: string,
|
||||
|
||||
/** The local user's Device ID. */
|
||||
_deviceId: string,
|
||||
|
||||
/** Interface to server-side secret storage */
|
||||
_secretStorage: ServerSideSecretStorage,
|
||||
) {
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -101,23 +125,7 @@ export class RustCrypto implements CryptoBackend {
|
||||
// through decryptEvent and hence get rid of this case.
|
||||
throw new Error("to-device event was not decrypted in preprocessToDeviceMessages");
|
||||
}
|
||||
const res = (await this.olmMachine.decryptRoomEvent(
|
||||
JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
type: event.getWireType(),
|
||||
sender: event.getSender(),
|
||||
state_key: event.getStateKey(),
|
||||
content: event.getWireContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
}),
|
||||
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
|
||||
)) as RustSdkCryptoJs.DecryptedRoomEvent;
|
||||
return {
|
||||
clearEvent: JSON.parse(res.event),
|
||||
claimedEd25519Key: res.senderClaimedEd25519Key,
|
||||
senderCurve25519Key: res.senderCurve25519Key,
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
return await this.eventDecryptor.attemptEventDecryption(event);
|
||||
}
|
||||
|
||||
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
|
||||
@@ -143,9 +151,30 @@ export class RustCrypto implements CryptoBackend {
|
||||
return new UserTrustLevel(false, false, false);
|
||||
}
|
||||
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): undefined {
|
||||
// TODO
|
||||
return new DeviceTrustLevel(false, false, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
* The cross-signing API is currently UNSTABLE and may change without notice.
|
||||
*
|
||||
* @param userId - the user ID to get the cross-signing info for.
|
||||
*
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
public getStoredCrossSigningForUser(userId: string): null {
|
||||
// TODO
|
||||
return null;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -178,6 +207,146 @@ export class RustCrypto implements CryptoBackend {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* @param userIds - The users to fetch.
|
||||
* @param downloadUncached - If true, download the device list for users whose device list we are not
|
||||
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
|
||||
*
|
||||
* @returns A map `{@link DeviceMap}`.
|
||||
*/
|
||||
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
|
||||
const deviceMapByUserId = new Map<string, Map<string, Device>>();
|
||||
const rustTrackedUsers: Set<RustSdkCryptoJs.UserId> = await this.olmMachine.trackedUsers();
|
||||
|
||||
// Convert RustSdkCryptoJs.UserId to a `Set<string>`
|
||||
const trackedUsers = new Set<string>();
|
||||
rustTrackedUsers.forEach((rustUserId) => trackedUsers.add(rustUserId.toString()));
|
||||
|
||||
// Keep untracked user to download their keys after
|
||||
const untrackedUsers: Set<string> = new Set();
|
||||
|
||||
for (const userId of userIds) {
|
||||
// if this is a tracked user, we can just fetch the device list from the rust-sdk
|
||||
// (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's
|
||||
// devices: the rust-sdk will return the last-known device list, which will be good enough.)
|
||||
if (trackedUsers.has(userId)) {
|
||||
deviceMapByUserId.set(userId, await this.getUserDevices(userId));
|
||||
} else {
|
||||
untrackedUsers.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// for any users whose device lists we are not tracking, fall back to downloading the device list
|
||||
// over HTTP.
|
||||
if (downloadUncached && untrackedUsers.size >= 1) {
|
||||
const queryResult = await this.downloadDeviceList(untrackedUsers);
|
||||
Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) =>
|
||||
deviceMapByUserId.set(userId, deviceKeysToDeviceMap(deviceKeys)),
|
||||
);
|
||||
}
|
||||
|
||||
return deviceMapByUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device list for the given user from the olm machine
|
||||
* @param userId - Rust SDK UserId
|
||||
*/
|
||||
private async getUserDevices(userId: string): Promise<Map<string, Device>> {
|
||||
const rustUserId = new RustSdkCryptoJs.UserId(userId);
|
||||
const devices: RustSdkCryptoJs.UserDevices = await this.olmMachine.getUserDevices(rustUserId);
|
||||
return new Map(
|
||||
devices
|
||||
.devices()
|
||||
.map((device: RustSdkCryptoJs.Device) => [
|
||||
device.deviceId.toString(),
|
||||
rustDeviceToJsDevice(device, rustUserId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the given user keys by calling `/keys/query` request
|
||||
* @param untrackedUsers - download keys of these users
|
||||
*/
|
||||
private async downloadDeviceList(untrackedUsers: Set<string>): Promise<IDownloadKeyResult> {
|
||||
const queryBody: IQueryKeysRequest = { device_keys: {} };
|
||||
untrackedUsers.forEach((user) => (queryBody.device_keys[user] = []));
|
||||
|
||||
return await this.http.authedRequest(Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, {
|
||||
prefix: "",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getTrustCrossSignedDevices}.
|
||||
*/
|
||||
public getTrustCrossSignedDevices(): boolean {
|
||||
return this._trustCrossSignedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
public setTrustCrossSignedDevices(val: boolean): void {
|
||||
this._trustCrossSignedDevices = val;
|
||||
// TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged
|
||||
// events. Maybe we need to do the same?
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getDeviceVerificationStatus}.
|
||||
*/
|
||||
public async getDeviceVerificationStatus(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<DeviceVerificationStatus | null> {
|
||||
const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.DeviceId(deviceId),
|
||||
);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
return new DeviceVerificationStatus({
|
||||
signedByOwner: device.isCrossSignedByOwner(),
|
||||
crossSigningVerified: device.isCrossSigningTrusted(),
|
||||
localVerified: device.isLocallyTrusted(),
|
||||
trustCrossSignedDevices: this._trustCrossSignedDevices,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isCrossSigningReady}
|
||||
*/
|
||||
public async isCrossSigningReady(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
|
||||
*/
|
||||
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
|
||||
// TODO
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#boostrapCrossSigning}
|
||||
*/
|
||||
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
logger.log("Cross-signing ready");
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#isSecretStorageReady}
|
||||
*/
|
||||
public async isSecretStorageReady(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -189,20 +358,23 @@ export class RustCrypto implements CryptoBackend {
|
||||
* @param events - the received to-device messages
|
||||
* @param oneTimeKeysCounts - the received one time key counts
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
* @param devices - the received device list updates
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
*/
|
||||
private async receiveSyncChanges({
|
||||
events,
|
||||
oneTimeKeysCounts = new Map<string, number>(),
|
||||
unusedFallbackKeys = new Set<string>(),
|
||||
unusedFallbackKeys,
|
||||
devices = new RustSdkCryptoJs.DeviceLists(),
|
||||
}: {
|
||||
events?: IToDeviceEvent[];
|
||||
oneTimeKeysCounts?: Map<string, number>;
|
||||
unusedFallbackKeys?: Set<string>;
|
||||
devices?: RustSdkCryptoJs.DeviceLists;
|
||||
}): Promise<IToDeviceEvent[]> {
|
||||
const result = await this.olmMachine.receiveSyncChanges(
|
||||
events ? JSON.stringify(events) : "[]",
|
||||
new RustSdkCryptoJs.DeviceLists(),
|
||||
devices,
|
||||
oneTimeKeysCounts,
|
||||
unusedFallbackKeys,
|
||||
);
|
||||
@@ -222,22 +394,37 @@ export class RustCrypto implements CryptoBackend {
|
||||
return this.receiveSyncChanges({ events });
|
||||
}
|
||||
|
||||
/** called by the sync loop to preprocess one time key counts
|
||||
/** called by the sync loop to process one time key counts and unused fallback keys
|
||||
*
|
||||
* @param oneTimeKeysCounts - the received one time key counts
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
*/
|
||||
public async preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
|
||||
await this.receiveSyncChanges({ oneTimeKeysCounts });
|
||||
public async processKeyCounts(
|
||||
oneTimeKeysCounts?: Record<string, number>,
|
||||
unusedFallbackKeys?: string[],
|
||||
): Promise<void> {
|
||||
const mapOneTimeKeysCount = oneTimeKeysCounts && new Map<string, number>(Object.entries(oneTimeKeysCounts));
|
||||
const setUnusedFallbackKeys = unusedFallbackKeys && new Set<string>(unusedFallbackKeys);
|
||||
|
||||
if (mapOneTimeKeysCount !== undefined || setUnusedFallbackKeys !== undefined) {
|
||||
await this.receiveSyncChanges({
|
||||
oneTimeKeysCounts: mapOneTimeKeysCount,
|
||||
unusedFallbackKeys: setUnusedFallbackKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** called by the sync loop to preprocess unused fallback keys
|
||||
/** called by the sync loop to process the notification that device lists have
|
||||
* been changed.
|
||||
*
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @param deviceLists - device_lists field from /sync
|
||||
*/
|
||||
public async preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
|
||||
await this.receiveSyncChanges({ unusedFallbackKeys });
|
||||
public async processDeviceLists(deviceLists: IDeviceLists): Promise<void> {
|
||||
const devices = new RustSdkCryptoJs.DeviceLists(
|
||||
deviceLists.changed?.map((userId) => new RustSdkCryptoJs.UserId(userId)),
|
||||
deviceLists.left?.map((userId) => new RustSdkCryptoJs.UserId(userId)),
|
||||
);
|
||||
await this.receiveSyncChanges({ devices });
|
||||
}
|
||||
|
||||
/** called by the sync loop on m.room.encrypted events
|
||||
@@ -303,6 +490,43 @@ export class RustCrypto implements CryptoBackend {
|
||||
enc.onRoomMembership(member);
|
||||
}
|
||||
|
||||
/** Callback for OlmMachine.registerRoomKeyUpdatedCallback
|
||||
*
|
||||
* Called by the rust-sdk whenever there is an update to (megolm) room keys. We
|
||||
* check if we have any events waiting for the given keys, and schedule them for
|
||||
* a decryption retry if so.
|
||||
*
|
||||
* @param keys - details of the updated keys
|
||||
*/
|
||||
public async onRoomKeysUpdated(keys: RustSdkCryptoJs.RoomKeyInfo[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
this.onRoomKeyUpdated(key);
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomKeyUpdated(key: RustSdkCryptoJs.RoomKeyInfo): void {
|
||||
logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`);
|
||||
const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key);
|
||||
if (pendingList.length === 0) return;
|
||||
|
||||
logger.debug(
|
||||
"Retrying decryption on events:",
|
||||
pendingList.map((e) => `${e.getId()}`),
|
||||
);
|
||||
|
||||
// Have another go at decrypting events with this key.
|
||||
//
|
||||
// We don't want to end up blocking the callback from Rust, which could otherwise end up dropping updates,
|
||||
// so we don't wait for the decryption to complete. In any case, there is no need to wait:
|
||||
// MatrixEvent.attemptDecryption ensures that there is only one decryption attempt happening at once,
|
||||
// and deduplicates repeated attempts for the same event.
|
||||
for (const ev of pendingList) {
|
||||
ev.attemptDecryption(this, { isRetry: true }).catch((_e) => {
|
||||
logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Outgoing requests
|
||||
@@ -332,3 +556,104 @@ export class RustCrypto implements CryptoBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventDecryptor {
|
||||
/**
|
||||
* Events which we couldn't decrypt due to unknown sessions / indexes.
|
||||
*
|
||||
* Map from senderKey to sessionId to Set of MatrixEvents
|
||||
*/
|
||||
private eventsPendingKey = new MapWithDefault<string, MapWithDefault<string, Set<MatrixEvent>>>(
|
||||
() => new MapWithDefault<string, Set<MatrixEvent>>(() => new Set()),
|
||||
);
|
||||
|
||||
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine) {}
|
||||
|
||||
public async attemptEventDecryption(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
logger.info("Attempting decryption of event", event);
|
||||
// add the event to the pending list *before* attempting to decrypt.
|
||||
// then, if the key turns up while decryption is in progress (and
|
||||
// decryption fails), we will schedule a retry.
|
||||
// (fixes https://github.com/vector-im/element-web/issues/5001)
|
||||
this.addEventToPendingList(event);
|
||||
|
||||
const res = (await this.olmMachine.decryptRoomEvent(
|
||||
JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
type: event.getWireType(),
|
||||
sender: event.getSender(),
|
||||
state_key: event.getStateKey(),
|
||||
content: event.getWireContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
}),
|
||||
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
|
||||
)) as RustSdkCryptoJs.DecryptedRoomEvent;
|
||||
|
||||
// Success. We can remove the event from the pending list, if
|
||||
// that hasn't already happened.
|
||||
this.removeEventFromPendingList(event);
|
||||
|
||||
return {
|
||||
clearEvent: JSON.parse(res.event),
|
||||
claimedEd25519Key: res.senderClaimedEd25519Key,
|
||||
senderCurve25519Key: res.senderCurve25519Key,
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for events which are waiting for a given megolm session
|
||||
*
|
||||
* Returns a list of events which were encrypted by `session` and could not be decrypted
|
||||
*
|
||||
* @param session -
|
||||
*/
|
||||
public getEventsPendingRoomKey(session: RustSdkCryptoJs.RoomKeyInfo): MatrixEvent[] {
|
||||
const senderPendingEvents = this.eventsPendingKey.get(session.senderKey.toBase64());
|
||||
if (!senderPendingEvents) return [];
|
||||
|
||||
const sessionPendingEvents = senderPendingEvents.get(session.sessionId);
|
||||
if (!sessionPendingEvents) return [];
|
||||
|
||||
const roomId = session.roomId.toString();
|
||||
return [...sessionPendingEvents].filter((ev) => ev.getRoomId() === roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the list of those awaiting their session keys.
|
||||
*/
|
||||
private addEventToPendingList(event: MatrixEvent): void {
|
||||
const content = event.getWireContent();
|
||||
const senderKey = content.sender_key;
|
||||
const sessionId = content.session_id;
|
||||
|
||||
const senderPendingEvents = this.eventsPendingKey.getOrCreate(senderKey);
|
||||
const sessionPendingEvents = senderPendingEvents.getOrCreate(sessionId);
|
||||
sessionPendingEvents.add(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event from the list of those awaiting their session keys.
|
||||
*/
|
||||
private removeEventFromPendingList(event: MatrixEvent): void {
|
||||
const content = event.getWireContent();
|
||||
const senderKey = content.sender_key;
|
||||
const sessionId = content.session_id;
|
||||
|
||||
const senderPendingEvents = this.eventsPendingKey.get(senderKey);
|
||||
if (!senderPendingEvents) return;
|
||||
|
||||
const sessionPendingEvents = senderPendingEvents.get(sessionId);
|
||||
if (!sessionPendingEvents) return;
|
||||
|
||||
sessionPendingEvents.delete(event);
|
||||
|
||||
// also clean up the higher-level maps if they are now empty
|
||||
if (sessionPendingEvents.size === 0) {
|
||||
senderPendingEvents.delete(sessionId);
|
||||
if (senderPendingEvents.size === 0) {
|
||||
this.eventsPendingKey.delete(senderKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -18,11 +18,10 @@ limitations under the License.
|
||||
* This is an internal module which manages queuing, scheduling and retrying
|
||||
* of requests.
|
||||
*/
|
||||
import * as utils from "./utils";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { EventType } from "./@types/event";
|
||||
import { IDeferred } from "./utils";
|
||||
import { defer, IDeferred, removeElement } from "./utils";
|
||||
import { ConnectionError, MatrixError } from "./http-api";
|
||||
import { ISendEventResponse } from "./@types/requests";
|
||||
|
||||
@@ -175,7 +174,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
return false;
|
||||
}
|
||||
let removed = false;
|
||||
utils.removeElement(this.queues[name], (element) => {
|
||||
removeElement(this.queues[name], (element) => {
|
||||
if (element.event.getId() === event.getId()) {
|
||||
// XXX we should probably reject the promise?
|
||||
// https://github.com/matrix-org/matrix-js-sdk/issues/496
|
||||
@@ -214,15 +213,15 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
if (!this.queues[queueName]) {
|
||||
this.queues[queueName] = [];
|
||||
}
|
||||
const defer = utils.defer<T>();
|
||||
const deferred = defer<T>();
|
||||
this.queues[queueName].push({
|
||||
event: event,
|
||||
defer: defer,
|
||||
defer: deferred,
|
||||
attempts: 0,
|
||||
});
|
||||
debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
|
||||
this.startProcessingQueues();
|
||||
return defer.promise;
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private startProcessingQueues(): void {
|
||||
@@ -282,7 +281,7 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
);
|
||||
if (waitTimeMs === -1) {
|
||||
// give up (you quitter!)
|
||||
debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId());
|
||||
logger.info("Queue '%s' giving up on event %s", queueName, obj.event.getId());
|
||||
// remove this from the queue
|
||||
this.clearQueue(queueName, err);
|
||||
} else {
|
||||
@@ -298,11 +297,11 @@ export class MatrixScheduler<T = ISendEventResponse> {
|
||||
if (index >= 0) {
|
||||
this.activeQueues.splice(index, 1);
|
||||
}
|
||||
debuglog("Stopping queue '%s' as it is now empty", queueName);
|
||||
logger.info("Stopping queue '%s' as it is now empty", queueName);
|
||||
}
|
||||
|
||||
private clearQueue(queueName: string, err: unknown): void {
|
||||
debuglog("clearing queue '%s'", queueName);
|
||||
logger.info("clearing queue '%s'", queueName);
|
||||
let obj: IQueueEntry<T> | undefined;
|
||||
while ((obj = this.removeNextEvent(queueName))) {
|
||||
obj.defer.reject(err);
|
||||
|
||||
@@ -20,6 +20,15 @@ limitations under the License.
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#storage
|
||||
*/
|
||||
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
import { ClientEvent, ClientEventHandlerMap } from "./client";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./crypto/aes";
|
||||
import { randomString } from "./randomstring";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
|
||||
|
||||
/**
|
||||
* Common base interface for Secret Storage Keys.
|
||||
*
|
||||
@@ -86,3 +95,580 @@ export interface PassphraseInfo {
|
||||
/** The number of bits to generate. Defaults to 256. */
|
||||
bits?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link ServerSideSecretStorageImpl#addKey}.
|
||||
*/
|
||||
export interface AddSecretStorageKeyOpts {
|
||||
pubkey?: string;
|
||||
passphrase?: PassphraseInfo;
|
||||
name?: string;
|
||||
key?: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for {@link ServerSideSecretStorageImpl#getKey}.
|
||||
*/
|
||||
export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription];
|
||||
|
||||
/**
|
||||
* Return type for {@link ServerSideSecretStorageImpl#addKey}.
|
||||
*/
|
||||
export type SecretStorageKeyObject = {
|
||||
/** The ID of the key */
|
||||
keyId: string;
|
||||
/** details about the key */
|
||||
keyInfo: SecretStorageKeyDescription;
|
||||
};
|
||||
|
||||
/** Interface for managing account data on the server.
|
||||
*
|
||||
* A subset of {@link MatrixClient}.
|
||||
*/
|
||||
export interface AccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> {
|
||||
/**
|
||||
* Get account data event of given type for the current user. This variant
|
||||
* gets account data directly from the homeserver if the local store is not
|
||||
* ready, which can be useful very early in startup before the initial sync.
|
||||
*
|
||||
* @param eventType - The type of account data
|
||||
* @returns The contents of the given account data event, or `null` if the event is not found
|
||||
*/
|
||||
getAccountDataFromServer: <T extends Record<string, any>>(eventType: string) => Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set account data event for the current user, with retries
|
||||
*
|
||||
* @param eventType - The type of account data
|
||||
* @param content - the content object to be set
|
||||
* @returns an empty object
|
||||
*/
|
||||
setAccountData: (eventType: string, content: any) => Promise<{}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application callbacks for use with {@link SecretStorage.ServerSideSecretStorageImpl}
|
||||
*/
|
||||
export interface SecretStorageCallbacks {
|
||||
/**
|
||||
* Called to retrieve a secret storage encryption key
|
||||
*
|
||||
* Before a secret can be stored in server-side storage, it must be encrypted with one or more
|
||||
* keys. Similarly, after it has been retrieved from storage, it must be decrypted with one of
|
||||
* the keys it was encrypted with. These encryption keys are known as "secret storage keys".
|
||||
*
|
||||
* Descriptions of the secret storage keys are also stored in server-side storage, per the
|
||||
* [matrix specification](https://spec.matrix.org/v1.6/client-server-api/#key-storage), so
|
||||
* before a key can be used in this way, it must have been stored on the server. This is
|
||||
* done via {@link SecretStorage.ServerSideSecretStorage#addKey}.
|
||||
*
|
||||
* Obviously the keys themselves are not stored server-side, so the js-sdk calls this callback
|
||||
* in order to retrieve a secret storage key from the application.
|
||||
*
|
||||
* @param keys - An options object, containing only the property `keys`.
|
||||
*
|
||||
* @param name - the name of the *secret* (NB: not the encryption key) being stored or retrieved.
|
||||
* This is the "event type" stored in account data.
|
||||
*
|
||||
* @returns a pair [`keyId`, `privateKey`], where `keyId` is one of the keys from the `keys` parameter,
|
||||
* and `privateKey` is the raw private encryption key, as appropriate for the encryption algorithm.
|
||||
* (For `m.secret_storage.v1.aes-hmac-sha2`, it is the input to an HKDF as defined in the
|
||||
* [specification](https://spec.matrix.org/v1.6/client-server-api/#msecret_storagev1aes-hmac-sha2).)
|
||||
*
|
||||
* Alternatively, if none of the keys are known, may return `null` — in which case the original
|
||||
* storage/retrieval operation will fail with an exception.
|
||||
*/
|
||||
getSecretStorageKey?: (
|
||||
keys: {
|
||||
/**
|
||||
* details of the secret storage keys required: a map from the key ID
|
||||
* (excluding the `m.secret_storage.key.` prefix) to details of the key.
|
||||
*
|
||||
* When storing a secret, `keys` will contain exactly one entry; this method will be called
|
||||
* once for each secret storage key to be used for encryption.
|
||||
*
|
||||
* For secret retrieval, `keys` may contain several entries, and the application can return
|
||||
* any one of the requested keys.
|
||||
*/
|
||||
keys: Record<string, SecretStorageKeyDescription>;
|
||||
},
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
}
|
||||
|
||||
interface SecretInfo {
|
||||
encrypted: {
|
||||
[keyId: string]: IEncryptedPayload;
|
||||
};
|
||||
}
|
||||
|
||||
interface Decryptors {
|
||||
encrypt: (plaintext: string) => Promise<IEncryptedPayload>;
|
||||
decrypt: (ciphertext: IEncryptedPayload) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface provided by SecretStorage implementations
|
||||
*
|
||||
* Normally this will just be an {@link ServerSideSecretStorageImpl}, but for backwards
|
||||
* compatibility some methods allow other implementations.
|
||||
*/
|
||||
export interface ServerSideSecretStorage {
|
||||
/**
|
||||
* Add a key for encrypting secrets.
|
||||
*
|
||||
* @param algorithm - the algorithm used by the key.
|
||||
* @param opts - the options for the algorithm. The properties used
|
||||
* depend on the algorithm given.
|
||||
* @param keyId - the ID of the key. If not given, a random
|
||||
* ID will be generated.
|
||||
*
|
||||
* @returns details about the key.
|
||||
*/
|
||||
addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject>;
|
||||
|
||||
/**
|
||||
* Get the key information for a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns If the key was found, the return value is an array of
|
||||
* the form [keyId, keyInfo]. Otherwise, null is returned.
|
||||
* XXX: why is this an array when addKey returns an object?
|
||||
*/
|
||||
getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null>;
|
||||
|
||||
/**
|
||||
* Check whether we have a key with a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns Whether we have the key.
|
||||
*/
|
||||
hasKey(keyId?: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check whether a key matches what we expect based on the key info
|
||||
*
|
||||
* @param key - the key to check
|
||||
* @param info - the key info
|
||||
*
|
||||
* @returns whether or not the key matches
|
||||
*/
|
||||
checkKey(key: Uint8Array, info: SecretStorageKeyDescriptionAesV1): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Store an encrypted secret on the server.
|
||||
*
|
||||
* Details of the encryption keys to be used must previously have been stored in account data
|
||||
* (for example, via {@link ServerSideSecretStorage#addKey}.
|
||||
*
|
||||
* @param name - The name of the secret - i.e., the "event type" to be stored in the account data
|
||||
* @param secret - The secret contents.
|
||||
* @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key
|
||||
* (will throw if no default key is set).
|
||||
*/
|
||||
store(name: string, secret: string, keys?: string[] | null): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a secret from storage, and decrypt it.
|
||||
*
|
||||
* @param name - the name of the secret - i.e., the "event type" stored in the account data
|
||||
*
|
||||
* @returns the decrypted contents of the secret, or "undefined" if `name` is not found in
|
||||
* the user's account data.
|
||||
*/
|
||||
get(name: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Check if a secret is stored on the server.
|
||||
*
|
||||
* @param name - the name of the secret
|
||||
*
|
||||
* @returns map of key name to key info the secret is encrypted
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* key
|
||||
*/
|
||||
isStored(name: string): Promise<Record<string, SecretStorageKeyDescriptionAesV1> | null>;
|
||||
|
||||
/**
|
||||
* Get the current default key ID for encrypting secrets.
|
||||
*
|
||||
* @returns The default key ID or null if no default key ID is set
|
||||
*/
|
||||
getDefaultKeyId(): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Set the default key ID for encrypting secrets.
|
||||
*
|
||||
* @param keyId - The new default key ID
|
||||
*/
|
||||
setDefaultKeyId(keyId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of Server-side secret storage.
|
||||
*
|
||||
* Secret *sharing* is *not* implemented here: this class is strictly about the storage component of
|
||||
* SSSS.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#storage
|
||||
*/
|
||||
export class ServerSideSecretStorageImpl implements ServerSideSecretStorage {
|
||||
/**
|
||||
* Construct a new `SecretStorage`.
|
||||
*
|
||||
* Normally, it is unnecessary to call this directly, since MatrixClient automatically constructs one.
|
||||
* However, it may be useful to construct a new `SecretStorage`, if custom `callbacks` are required, for example.
|
||||
*
|
||||
* @param accountDataAdapter - interface for fetching and setting account data on the server. Normally an instance
|
||||
* of {@link MatrixClient}.
|
||||
* @param callbacks - application level callbacks for retrieving secret keys
|
||||
*/
|
||||
public constructor(
|
||||
private readonly accountDataAdapter: AccountDataClient,
|
||||
private readonly callbacks: SecretStorageCallbacks,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the current default key ID for encrypting secrets.
|
||||
*
|
||||
* @returns The default key ID or null if no default key ID is set
|
||||
*/
|
||||
public async getDefaultKeyId(): Promise<string | null> {
|
||||
const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>(
|
||||
"m.secret_storage.default_key",
|
||||
);
|
||||
if (!defaultKey) return null;
|
||||
return defaultKey.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default key ID for encrypting secrets.
|
||||
*
|
||||
* @param keyId - The new default key ID
|
||||
*/
|
||||
public setDefaultKeyId(keyId: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const listener = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
|
||||
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.accountDataAdapter.on(ClientEvent.AccountData, listener);
|
||||
|
||||
this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => {
|
||||
this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key for encrypting secrets.
|
||||
*
|
||||
* @param algorithm - the algorithm used by the key.
|
||||
* @param opts - the options for the algorithm. The properties used
|
||||
* depend on the algorithm given.
|
||||
* @param keyId - the ID of the key. If not given, a random
|
||||
* ID will be generated.
|
||||
*
|
||||
* @returns An object with:
|
||||
* keyId: the ID of the key
|
||||
* keyInfo: details about the key (iv, mac, passphrase)
|
||||
*/
|
||||
public async addKey(
|
||||
algorithm: string,
|
||||
opts: AddSecretStorageKeyOpts = {},
|
||||
keyId?: string,
|
||||
): Promise<SecretStorageKeyObject> {
|
||||
if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
throw new Error(`Unknown key algorithm ${algorithm}`);
|
||||
}
|
||||
|
||||
const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1;
|
||||
|
||||
if (opts.name) {
|
||||
keyInfo.name = opts.name;
|
||||
}
|
||||
|
||||
if (opts.passphrase) {
|
||||
keyInfo.passphrase = opts.passphrase;
|
||||
}
|
||||
if (opts.key) {
|
||||
const { iv, mac } = await calculateKeyCheck(opts.key);
|
||||
keyInfo.iv = iv;
|
||||
keyInfo.mac = mac;
|
||||
}
|
||||
|
||||
// Create a unique key id. XXX: this is racey.
|
||||
if (!keyId) {
|
||||
do {
|
||||
keyId = randomString(32);
|
||||
} while (
|
||||
await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
|
||||
`m.secret_storage.key.${keyId}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
|
||||
|
||||
return {
|
||||
keyId,
|
||||
keyInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key information for a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns If the key was found, the return value is an array of
|
||||
* the form [keyId, keyInfo]. Otherwise, null is returned.
|
||||
* XXX: why is this an array when addKey returns an object?
|
||||
*/
|
||||
public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
|
||||
if (!keyId) {
|
||||
keyId = await this.getDefaultKeyId();
|
||||
}
|
||||
if (!keyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
return keyInfo ? [keyId, keyInfo] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether we have a key with a given ID.
|
||||
*
|
||||
* @param keyId - The ID of the key to check
|
||||
* for. Defaults to the default key ID if not provided.
|
||||
* @returns Whether we have the key.
|
||||
*/
|
||||
public async hasKey(keyId?: string): Promise<boolean> {
|
||||
const key = await this.getKey(keyId);
|
||||
return Boolean(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a key matches what we expect based on the key info
|
||||
*
|
||||
* @param key - the key to check
|
||||
* @param info - the key info
|
||||
*
|
||||
* @returns whether or not the key matches
|
||||
*/
|
||||
public async checkKey(key: Uint8Array, info: SecretStorageKeyDescriptionAesV1): Promise<boolean> {
|
||||
if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (info.mac) {
|
||||
const { mac } = await calculateKeyCheck(key, info.iv);
|
||||
return trimTrailingEquals(info.mac) === trimTrailingEquals(mac);
|
||||
} else {
|
||||
// if we have no information, we have to assume the key is right
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unknown algorithm");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an encrypted secret on the server.
|
||||
*
|
||||
* Details of the encryption keys to be used must previously have been stored in account data
|
||||
* (for example, via {@link ServerSideSecretStorageImpl#addKey}. {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
|
||||
* key to decrypt the secret.
|
||||
*
|
||||
* @param name - The name of the secret - i.e., the "event type" to be stored in the account data
|
||||
* @param secret - The secret contents.
|
||||
* @param keys - The IDs of the keys to use to encrypt the secret, or null/undefined to use the default key.
|
||||
*/
|
||||
public async store(name: string, secret: string, keys?: string[] | null): Promise<void> {
|
||||
const encrypted: Record<string, IEncryptedPayload> = {};
|
||||
|
||||
if (!keys) {
|
||||
const defaultKeyId = await this.getDefaultKeyId();
|
||||
if (!defaultKeyId) {
|
||||
throw new Error("No keys specified and no default key present");
|
||||
}
|
||||
keys = [defaultKeyId];
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
throw new Error("Zero keys given to encrypt with!");
|
||||
}
|
||||
|
||||
for (const keyId of keys) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
if (!keyInfo) {
|
||||
throw new Error("Unknown key: " + keyId);
|
||||
}
|
||||
|
||||
// encrypt secret, based on the algorithm
|
||||
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
const keys = { [keyId]: keyInfo };
|
||||
const [, encryption] = await this.getSecretStorageKey(keys, name);
|
||||
encrypted[keyId] = await encryption.encrypt(secret);
|
||||
} else {
|
||||
logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm);
|
||||
// do nothing if we don't understand the encryption algorithm
|
||||
}
|
||||
}
|
||||
|
||||
// save encrypted secret
|
||||
await this.accountDataAdapter.setAccountData(name, { encrypted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret from storage, and decrypt it.
|
||||
*
|
||||
* {@link SecretStorageCallbacks#getSecretStorageKey} will be called to obtain a secret storage
|
||||
* key to decrypt the secret.
|
||||
*
|
||||
* @param name - the name of the secret - i.e., the "event type" stored in the account data
|
||||
*
|
||||
* @returns the decrypted contents of the secret, or "undefined" if `name` is not found in
|
||||
* the user's account data.
|
||||
*/
|
||||
public async get(name: string): Promise<string | undefined> {
|
||||
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretInfo>(name);
|
||||
if (!secretInfo) {
|
||||
return;
|
||||
}
|
||||
if (!secretInfo.encrypted) {
|
||||
throw new Error("Content is not encrypted!");
|
||||
}
|
||||
|
||||
// get possible keys to decrypt
|
||||
const keys: Record<string, SecretStorageKeyDescriptionAesV1> = {};
|
||||
for (const keyId of Object.keys(secretInfo.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
// only use keys we understand the encryption algorithm of
|
||||
if (keyInfo?.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
|
||||
keys[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(keys).length === 0) {
|
||||
throw new Error(
|
||||
`Could not decrypt ${name} because none of ` +
|
||||
`the keys it is encrypted with are for a supported algorithm`,
|
||||
);
|
||||
}
|
||||
|
||||
// fetch private key from app
|
||||
const [keyId, decryption] = await this.getSecretStorageKey(keys, name);
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
|
||||
return decryption.decrypt(encInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a secret is stored on the server.
|
||||
*
|
||||
* @param name - the name of the secret
|
||||
*
|
||||
* @returns map of key name to key info the secret is encrypted
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* key
|
||||
*/
|
||||
public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescriptionAesV1> | null> {
|
||||
// check if secret exists
|
||||
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretInfo>(name);
|
||||
if (!secretInfo?.encrypted) return null;
|
||||
|
||||
const ret: Record<string, SecretStorageKeyDescriptionAesV1> = {};
|
||||
|
||||
// filter secret encryption keys with supported algorithm
|
||||
for (const keyId of Object.keys(secretInfo.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescriptionAesV1>(
|
||||
"m.secret_storage.key." + keyId,
|
||||
);
|
||||
if (!keyInfo) continue;
|
||||
const encInfo = secretInfo.encrypted[keyId];
|
||||
|
||||
// only use keys we understand the encryption algorithm of
|
||||
if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
|
||||
ret[keyId] = keyInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(ret).length ? ret : null;
|
||||
}
|
||||
|
||||
private async getSecretStorageKey(
|
||||
keys: Record<string, SecretStorageKeyDescriptionAesV1>,
|
||||
name: string,
|
||||
): Promise<[string, Decryptors]> {
|
||||
if (!this.callbacks.getSecretStorageKey) {
|
||||
throw new Error("No getSecretStorageKey callback supplied");
|
||||
}
|
||||
|
||||
const returned = await this.callbacks.getSecretStorageKey({ keys }, name);
|
||||
|
||||
if (!returned) {
|
||||
throw new Error("getSecretStorageKey callback returned falsey");
|
||||
}
|
||||
if (returned.length < 2) {
|
||||
throw new Error("getSecretStorageKey callback returned invalid data");
|
||||
}
|
||||
|
||||
const [keyId, privateKey] = returned;
|
||||
if (!keys[keyId]) {
|
||||
throw new Error("App returned unknown key from getSecretStorageKey!");
|
||||
}
|
||||
|
||||
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
const decryption = {
|
||||
encrypt: function (secret: string): Promise<IEncryptedPayload> {
|
||||
return encryptAES(secret, privateKey, name);
|
||||
},
|
||||
decrypt: function (encInfo: IEncryptedPayload): Promise<string> {
|
||||
return decryptAES(encInfo, privateKey, name);
|
||||
},
|
||||
};
|
||||
return [keyId, decryption];
|
||||
} else {
|
||||
throw new Error("Unknown key type: " + keys[keyId].algorithm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** trim trailing instances of '=' from a string
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param input - input string
|
||||
*/
|
||||
export function trimTrailingEquals(input: string): string {
|
||||
// according to Sonar and CodeQL, a regex such as /=+$/ is superlinear.
|
||||
// Not sure I believe it, but it's easy enough to work around.
|
||||
|
||||
// find the number of characters before the trailing =
|
||||
let i = input.length;
|
||||
while (i >= 1 && input.charCodeAt(i - 1) == 0x3d) i--;
|
||||
|
||||
// trim to the calculated length
|
||||
if (i < input.length) {
|
||||
return input.substring(0, i);
|
||||
} else {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-25
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room";
|
||||
import { logger } from "./logger";
|
||||
import * as utils from "./utils";
|
||||
import { promiseMapSeries } from "./utils";
|
||||
import { EventTimeline } from "./models/event-timeline";
|
||||
import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
|
||||
import {
|
||||
@@ -85,30 +85,16 @@ class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResp
|
||||
|
||||
public async onResponse(data: ExtensionE2EEResponse): Promise<void> {
|
||||
// Handle device list updates
|
||||
if (data["device_lists"]) {
|
||||
await this.crypto.handleDeviceListChanges(
|
||||
{
|
||||
oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :(
|
||||
},
|
||||
data["device_lists"],
|
||||
);
|
||||
if (data.device_lists) {
|
||||
await this.crypto.processDeviceLists(data.device_lists);
|
||||
}
|
||||
|
||||
// Handle one_time_keys_count
|
||||
if (data["device_one_time_keys_count"]) {
|
||||
const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0;
|
||||
this.crypto.updateOneTimeKeyCount(currentCount);
|
||||
}
|
||||
if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) {
|
||||
// The presence of device_unused_fallback_key_types indicates that the
|
||||
// server supports fallback keys. If there's no unused
|
||||
// signed_curve25519 fallback key we need a new one.
|
||||
const unusedFallbackKeys =
|
||||
data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"];
|
||||
this.crypto.setNeedsNewFallback(
|
||||
Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
|
||||
);
|
||||
}
|
||||
// Handle one_time_keys_count and unused_fallback_key_types
|
||||
await this.crypto.processKeyCounts(
|
||||
data.device_one_time_keys_count,
|
||||
data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"],
|
||||
);
|
||||
|
||||
this.crypto.onSyncCompleted({});
|
||||
}
|
||||
}
|
||||
@@ -740,8 +726,8 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
};
|
||||
|
||||
await utils.promiseMapSeries(stateEvents, processRoomEvent);
|
||||
await utils.promiseMapSeries(timelineEvents, processRoomEvent);
|
||||
await promiseMapSeries(stateEvents, processRoomEvent);
|
||||
await promiseMapSeries(timelineEvents, processRoomEvent);
|
||||
ephemeralEvents.forEach(function (e) {
|
||||
client.emit(ClientEvent.Event, e);
|
||||
});
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ export interface IStore {
|
||||
/**
|
||||
* Save does nothing as there is no backing data store.
|
||||
*/
|
||||
save(force?: boolean): void;
|
||||
save(force?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Startup does nothing.
|
||||
|
||||
@@ -15,8 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../sync-accumulator";
|
||||
import * as utils from "../utils";
|
||||
import * as IndexedDBHelpers from "../indexeddb-helpers";
|
||||
import { deepCopy, promiseTry } from "../utils";
|
||||
import { exists as idbExists } from "../indexeddb-helpers";
|
||||
import { logger } from "../logger";
|
||||
import { IStateEventWithRoomId, IStoredClientOpts } from "../matrix";
|
||||
import { ISavedSync } from "./index";
|
||||
@@ -122,7 +122,7 @@ function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> {
|
||||
export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
|
||||
dbName = "matrix-js-sdk:" + (dbName || "default");
|
||||
return IndexedDBHelpers.exists(indexedDB, dbName);
|
||||
return idbExists(indexedDB, dbName);
|
||||
}
|
||||
|
||||
private readonly dbName: string;
|
||||
@@ -380,7 +380,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
if (copy) {
|
||||
// We must deep copy the stored data so that the /sync processing code doesn't
|
||||
// corrupt the internal state of the sync accumulator (it adds non-clonable keys)
|
||||
return Promise.resolve(utils.deepCopy(data));
|
||||
return Promise.resolve(deepCopy(data));
|
||||
} else {
|
||||
return Promise.resolve(data);
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
*/
|
||||
private persistSyncData(nextBatch: string, roomsData: ISyncResponse["rooms"]): Promise<void> {
|
||||
logger.log("Persisting sync data up to", nextBatch);
|
||||
return utils.promiseTry<void>(() => {
|
||||
return promiseTry<void>(() => {
|
||||
const txn = this.db!.transaction(["sync"], "readwrite");
|
||||
const store = txn.objectStore("sync");
|
||||
store.put({
|
||||
@@ -456,7 +456,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
* @returns Promise which resolves if the events were persisted.
|
||||
*/
|
||||
private persistAccountData(accountData: IMinimalEvent[]): Promise<void> {
|
||||
return utils.promiseTry<void>(() => {
|
||||
return promiseTry<void>(() => {
|
||||
const txn = this.db!.transaction(["accountData"], "readwrite");
|
||||
const store = txn.objectStore("accountData");
|
||||
for (const event of accountData) {
|
||||
@@ -475,7 +475,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
* @returns Promise which resolves if the users were persisted.
|
||||
*/
|
||||
private persistUserPresenceEvents(tuples: UserTuple[]): Promise<void> {
|
||||
return utils.promiseTry<void>(() => {
|
||||
return promiseTry<void>(() => {
|
||||
const txn = this.db!.transaction(["users"], "readwrite");
|
||||
const store = txn.objectStore("users");
|
||||
for (const tuple of tuples) {
|
||||
@@ -495,7 +495,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
* @returns A list of presence events in their raw form.
|
||||
*/
|
||||
public getUserPresenceEvents(): Promise<UserTuple[]> {
|
||||
return utils.promiseTry<UserTuple[]>(() => {
|
||||
return promiseTry<UserTuple[]>(() => {
|
||||
const txn = this.db!.transaction(["users"], "readonly");
|
||||
const store = txn.objectStore("users");
|
||||
return selectQuery(store, undefined, (cursor) => {
|
||||
@@ -510,7 +510,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
*/
|
||||
private loadAccountData(): Promise<IMinimalEvent[]> {
|
||||
logger.log(`LocalIndexedDBStoreBackend: loading account data...`);
|
||||
return utils.promiseTry<IMinimalEvent[]>(() => {
|
||||
return promiseTry<IMinimalEvent[]>(() => {
|
||||
const txn = this.db!.transaction(["accountData"], "readonly");
|
||||
const store = txn.objectStore("accountData");
|
||||
return selectQuery(store, undefined, (cursor) => {
|
||||
@@ -528,7 +528,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
*/
|
||||
private loadSyncData(): Promise<ISyncData> {
|
||||
logger.log(`LocalIndexedDBStoreBackend: loading sync data...`);
|
||||
return utils.promiseTry<ISyncData>(() => {
|
||||
return promiseTry<ISyncData>(() => {
|
||||
const txn = this.db!.transaction(["sync"], "readonly");
|
||||
const store = txn.objectStore("sync");
|
||||
return selectQuery(store, undefined, (cursor) => {
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ export class MemoryStore implements IStore {
|
||||
* @param force - True to force a save (but the memory
|
||||
* store still can't save anything)
|
||||
*/
|
||||
public save(force: boolean): void {}
|
||||
public save(force: boolean): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup does nothing as this store doesn't require starting up.
|
||||
|
||||
+3
-1
@@ -189,7 +189,9 @@ export class StubStore implements IStore {
|
||||
/**
|
||||
* Save does nothing as there is no backing data store.
|
||||
*/
|
||||
public save(): void {}
|
||||
public save(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup does nothing.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user