Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89d0133c61 | |||
| 5973a15f68 | |||
| 3c28cfc96a | |||
| c99378501b | |||
| b10a804a03 | |||
| 2337d5a7af | |||
| 5a49ed4ebb | |||
| 22db9eb245 | |||
| 4cddc7397d | |||
| 418b69914a | |||
| 0082964345 | |||
| 96b3c79566 | |||
| 41a6f18125 | |||
| a2b2e8dbdf | |||
| abd920f0f4 | |||
| 5333d0e0ba | |||
| c885542628 | |||
| 81b58388ee | |||
| 0d486eaade | |||
| 76b9c3950b | |||
| 6176cb6d7b | |||
| b9a107f9ff | |||
| 06e8cea63d | |||
| 815c36e075 | |||
| d355073d10 | |||
| 2ef3ebb466 | |||
| 92f7481fdd | |||
| 8df30ed068 | |||
| 49624d5d73 | |||
| 8e5128ad3c | |||
| 8ac2f2a78d | |||
| 630440c59c | |||
| 6932437360 | |||
| f381dfe991 | |||
| 6a98b835a8 | |||
| ae01c0915c | |||
| a597a9d660 | |||
| 9661cdecf2 | |||
| 533070c603 | |||
| eae1c2d48b | |||
| 070a89d89d | |||
| ffc9fb34d0 | |||
| d030c83cee | |||
| c115e055c6 | |||
| 0f65088fd9 | |||
| a1ff63adcb | |||
| 31fc5f23be | |||
| febef3fc7c | |||
| f2625348d8 | |||
| 6d1d04782a | |||
| 5e67a173c8 | |||
| 9780643ce7 | |||
| 608c6ece56 | |||
| 3a55efb476 | |||
| 48d4f1b0cc | |||
| a80e90b42d | |||
| 2c13e133b7 | |||
| 68898aeff2 | |||
| f604ab2f63 | |||
| b7d45e83f8 | |||
| db0e3cfbb0 | |||
| 87b90cc983 | |||
| cc9545e313 | |||
| ed2792e6d8 | |||
| dd53ec722f | |||
| b03dc6ac43 | |||
| 13c7e0ebda | |||
| 2cd63ca4b9 | |||
| 479c4278a6 | |||
| 636fc3daaa | |||
| 1d1309870a | |||
| 13b8f01062 | |||
| cd672ec4cf | |||
| 2363703b64 | |||
| 1250bb8833 | |||
| 016ef12c4a | |||
| 84d193a9a2 | |||
| 9d5f1bb4fc | |||
| 228131edf3 | |||
| 23ad637aad | |||
| 103617c70e | |||
| 8d84621b07 | |||
| 41878c7a43 | |||
| f31e83fd03 | |||
| b515cdbdbb | |||
| f4b6f91ee2 | |||
| df4536492c | |||
| 2e98da4224 | |||
| 48d9d9b4c9 | |||
| d90ae11e2b | |||
| 3f246c6080 | |||
| 68911520d3 | |||
| 393a8d0cdb | |||
| 51b63092b4 | |||
| b49c9639b9 | |||
| c588611fc0 | |||
| 5b34e4beaf | |||
| 91f16e5e8e | |||
| 9cf257da0e | |||
| 188de3c4c8 | |||
| 67019a3486 | |||
| a39b1203f2 | |||
| c49a527e5e |
+11
-4
@@ -1,8 +1,15 @@
|
||||
* @matrix-org/element-web
|
||||
/.github/workflows/** @matrix-org/element-web-app-team
|
||||
/package.json @matrix-org/element-web-app-team
|
||||
/yarn.lock @matrix-org/element-web-app-team
|
||||
* @matrix-org/element-web-reviewers
|
||||
/.github/workflows/** @matrix-org/element-web-team
|
||||
/package.json @matrix-org/element-web-team
|
||||
/yarn.lock @matrix-org/element-web-team
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/src/matrixrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/webrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/matrixrtc @matrix-org/element-call-reviewers
|
||||
|
||||
/src/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/src/rust-crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/integ/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/crypto.spec.ts @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/rust-crypto @matrix-org/element-crypto-web-reviewers
|
||||
|
||||
@@ -11,18 +11,16 @@ jobs:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: docs
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v2
|
||||
uses: matrix-org/netlify-pr-preview@v3
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -5,13 +5,9 @@ on:
|
||||
|
||||
pull_request: {}
|
||||
|
||||
# For now at least, we don't run this or the cypress-tests against pushes
|
||||
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
# Note that if we later choose to do so, we'll need to find a way to stop
|
||||
# the results in Cypress Cloud from clobbering those from the 'develop'
|
||||
# branch of matrix-react-sdk.
|
||||
#
|
||||
#push:
|
||||
# branches: [develop, master]
|
||||
concurrency:
|
||||
@@ -20,7 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.84.1
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.88.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# cypress tests (with access to repo secrets)
|
||||
# matrix-react-sdk playwright tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk Cypress End to End Tests
|
||||
name: matrix-react-sdk End to End Tests
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build downstream artifacts"]
|
||||
@@ -13,35 +13,31 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
|
||||
# We only want to run the cypress tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume 4 concurrent runners.
|
||||
playwright:
|
||||
name: Playwright
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume multiple concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@f6ef476f7905cc2b1f060f1a360b482e7546e682
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
secrets:
|
||||
# secrets are not automatically shared with called workflows, so share the cypress dashboard key, and the Kiwi login details
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
|
||||
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
|
||||
deployments: write
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
# We want to make the cypress tests a required check for the merge queue.
|
||||
# We want to make the Playwright tests a required check for the merge queue.
|
||||
#
|
||||
# Unfortunately, github doesn't distinguish between "checks needed for branch
|
||||
# Unfortunately, GitHub doesn't distinguish between "checks needed for branch
|
||||
# protection" (ie, the things that must pass before the PR will even be added
|
||||
# to the merge queue) and "checks needed in the merge queue". We just have to add
|
||||
# the check to the branch protection list.
|
||||
#
|
||||
# Ergo, if we know we're not going to run the cypress tests, we need to add a
|
||||
# Ergo, if we know we're not going to run the Playwright tests, we need to add a
|
||||
# passing status check manually.
|
||||
mark_skipped:
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
@@ -49,10 +45,14 @@ jobs:
|
||||
statuses: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Sibz/github-status-action@650dd1a882a76dbbbc4576fb5974b8d22f29847f # v1.1.6
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: success
|
||||
description: Cypress skipped
|
||||
context: "${{ github.workflow }} / cypress"
|
||||
description: Playwright skipped
|
||||
|
||||
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
|
||||
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
|
||||
context: "${{ github.workflow }} / end-to-end-tests"
|
||||
|
||||
sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
@@ -5,7 +5,13 @@ on:
|
||||
secrets:
|
||||
SONAR_TOKEN:
|
||||
required: true
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
inputs:
|
||||
sharded:
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
|
||||
extra_args:
|
||||
type: string
|
||||
required: false
|
||||
@@ -13,11 +19,13 @@ on:
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event != 'merge_group'
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Cypress is done.
|
||||
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -25,24 +33,57 @@ jobs:
|
||||
sha: ${{ github.event.workflow_run.head_sha }}
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: coverage-*
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.7
|
||||
# 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:
|
||||
skip_checkout: true
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
version_cmd: "cat package.json | jq -r .version"
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
coverage_run_id: ${{ github.event.workflow_run.id }}
|
||||
coverage_workflow_name: tests.yml
|
||||
coverage_extract_path: coverage
|
||||
extra_args: ${{ inputs.extra_args }}
|
||||
extra_args: |
|
||||
${{ inputs.extra_args }}
|
||||
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
|
||||
-Dsonar.testExecutionReportPaths=${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
|
||||
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -8,38 +8,12 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
|
||||
prepare:
|
||||
name: Prepare
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
|
||||
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
steps:
|
||||
# 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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: coverage
|
||||
path: coverage
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
|
||||
sonarqube:
|
||||
name: 🩻 SonarQube
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
needs: prepare
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
with:
|
||||
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
|
||||
sharded: true
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
|
||||
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
coverage
|
||||
!coverage/lcov-report
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
+2
-1
@@ -25,5 +25,6 @@ out
|
||||
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
||||
/CHANGELOG.md
|
||||
|
||||
# This file is also autogenerated
|
||||
# These files are also autogenerated
|
||||
/spec/test-utils/test-data/index.ts
|
||||
/spec/test-utils/test_indexeddb_cryptostore_dump/dump.json
|
||||
|
||||
@@ -1,3 +1,57 @@
|
||||
Changes in [31.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.2.0) (2024-01-31)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Emit events during migration from libolm ([#3982](https://github.com/matrix-org/matrix-js-sdk/pull/3982)). Contributed by @richvdh.
|
||||
* Support for migration from from libolm ([#3978](https://github.com/matrix-org/matrix-js-sdk/pull/3978)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* ElementR | backup: call expensive `roomKeyCounts` less often ([#4015](https://github.com/matrix-org/matrix-js-sdk/pull/4015)). Contributed by @BillCarsonFr.
|
||||
* Decrypt and Import full backups in chunk with progress ([#4005](https://github.com/matrix-org/matrix-js-sdk/pull/4005)). Contributed by @BillCarsonFr.
|
||||
* Fix new threads not appearing. ([#4009](https://github.com/matrix-org/matrix-js-sdk/pull/4009)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [31.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.1.0) (2024-01-19)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Broaden spec version support ([#4016](https://github.com/matrix-org/matrix-js-sdk/pull/4016)). Contributed by @RiotRobot.
|
||||
|
||||
|
||||
Changes in [31.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.0.0) (2024-01-16)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
|
||||
* Send authenticated /versions request ([#3968](https://github.com/matrix-org/matrix-js-sdk/pull/3968)). Contributed by @dbkr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Revert "Bump matrix-sdk-crypto-wasm to 3.6.0" ([#3991](https://github.com/matrix-org/matrix-js-sdk/pull/3991)). Contributed by @andybalaam.
|
||||
* #22606 Fix "Remove" button to users without "m.room.redaction" ([#3981](https://github.com/matrix-org/matrix-js-sdk/pull/3981)). Contributed by @rashmitpankhania.
|
||||
* ElementR: Ensure Encryption order per room ([#3973](https://github.com/matrix-org/matrix-js-sdk/pull/3973)). Contributed by @BillCarsonFr.
|
||||
* Element-R: fix `bootstrapSecretStorage` not resetting key backup when requested ([#3976](https://github.com/matrix-org/matrix-js-sdk/pull/3976)). Contributed by @uhoreg.
|
||||
|
||||
|
||||
Changes in [30.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.3.0) (2023-12-19)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Element-R: disable sending room key requests ([#3939](https://github.com/matrix-org/matrix-js-sdk/pull/3939)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix notifications appearing for old events ([#3946](https://github.com/matrix-org/matrix-js-sdk/pull/3946)). Contributed by @dbkr.
|
||||
* Don't back up keys that we got from backup ([#3934](https://github.com/matrix-org/matrix-js-sdk/pull/3934)). Contributed by @uhoreg.
|
||||
* Fix upload with empty Content-Type ([#3918](https://github.com/matrix-org/matrix-js-sdk/pull/3918)). Contributed by @JakubOnderka.
|
||||
* Prevent phantom notifications from events not in a room's timeline ([#3942](https://github.com/matrix-org/matrix-js-sdk/pull/3942)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [30.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.2.0) (2023-12-05)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -58,7 +58,7 @@ await client.startClient({ initialSyncLimit: 10 });
|
||||
You can perform a call to `/sync` to get the current state of the client:
|
||||
|
||||
```javascript
|
||||
client.once("sync", function (state, prevState, res) {
|
||||
client.once(ClientEvent.sync, function (state, prevState, res) {
|
||||
if (state === "PREPARED") {
|
||||
console.log("prepared");
|
||||
} else {
|
||||
@@ -83,7 +83,7 @@ client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
|
||||
To listen for message events:
|
||||
|
||||
```javascript
|
||||
client.on("Room.timeline", function (event, room, toStartOfTimeline) {
|
||||
client.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
|
||||
if (event.getType() !== "m.room.message") {
|
||||
return; // only use messages
|
||||
}
|
||||
@@ -144,12 +144,12 @@ are updated.
|
||||
|
||||
```javascript
|
||||
// Listen for low-level MatrixEvents
|
||||
client.on("event", function (event) {
|
||||
client.on(ClientEvent.Event, function (event) {
|
||||
console.log(event.getType());
|
||||
});
|
||||
|
||||
// Listen for typing changes
|
||||
client.on("RoomMember.typing", function (event, member) {
|
||||
client.on(RoomMemberEvent.Typing, function (event, member) {
|
||||
if (member.typing) {
|
||||
console.log(member.name + " is typing...");
|
||||
} else {
|
||||
@@ -211,7 +211,7 @@ const matrixClient = sdk.createClient({
|
||||
### Automatically join rooms when invited
|
||||
|
||||
```javascript
|
||||
matrixClient.on("RoomMember.membership", function (event, member) {
|
||||
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
|
||||
if (member.membership === "invite" && member.userId === myUserId) {
|
||||
matrixClient.joinRoom(member.roomId).then(function () {
|
||||
console.log("Auto-joined %s", member.roomId);
|
||||
@@ -225,7 +225,7 @@ matrixClient.startClient();
|
||||
### Print out messages for all rooms
|
||||
|
||||
```javascript
|
||||
matrixClient.on("Room.timeline", function (event, room, toStartOfTimeline) {
|
||||
matrixClient.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
|
||||
if (toStartOfTimeline) {
|
||||
return; // don't print paginated results
|
||||
}
|
||||
@@ -257,7 +257,7 @@ Output:
|
||||
### Print out membership lists whenever they are changed
|
||||
|
||||
```javascript
|
||||
matrixClient.on("RoomState.members", function (event, state, member) {
|
||||
matrixClient.on(RoomStateEvent.Members, function (event, state, member) {
|
||||
const room = matrixClient.getRoom(state.roomId);
|
||||
if (!room) {
|
||||
return;
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "30.2.0",
|
||||
"version": "31.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -16,7 +16,7 @@
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
|
||||
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
|
||||
"lint:js-fix": "prettier --loglevel=warn --write . && eslint --fix src spec",
|
||||
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||
"test": "jest",
|
||||
@@ -52,7 +52,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^3.1.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^4.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -91,23 +91,23 @@
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"allchange": "^1.0.6",
|
||||
"babel-jest": "^29.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.53.0",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"eslint-plugin-jsdoc": "^48.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^49.0.0",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "^8.0.3",
|
||||
@@ -117,7 +117,7 @@
|
||||
"jest-mock": "^29.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"prettier": "3.1.1",
|
||||
"rimraf": "^5.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typedoc": "^0.24.0",
|
||||
|
||||
@@ -31,9 +31,12 @@ import {
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
} from "../../test-utils/test-data";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -97,6 +100,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
|
||||
// Silence warnings from the backup manager
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND" },
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
@@ -236,6 +245,53 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("can bootstrapCrossSigning twice", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// a second call should do nothing except GET requests
|
||||
fetchMock.mockClear();
|
||||
await bootstrapCrossSigning(authDict);
|
||||
const calls = fetchMock.calls((url, opts) => opts.method != "GET");
|
||||
expect(calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
newBackendOnly("will upload existing cross-signing keys to an established secret storage", async () => {
|
||||
// This rather obscure codepath covers the case that:
|
||||
// - 4S is set up and working
|
||||
// - our device has private cross-signing keys, but has not published them to 4S
|
||||
//
|
||||
// To arrange that, we call `bootstrapCrossSigning` on our main device, and then (pretend to) set up 4S from
|
||||
// a *different* device. Then, when we call `bootstrapCrossSigning` again, it should do the honours.
|
||||
|
||||
mockSetupCrossSigningRequests();
|
||||
const accountDataAccumulator = new AccountDataAccumulator();
|
||||
accountDataAccumulator.interceptGetAccountData();
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// Pretend that another device has uploaded a 4S key
|
||||
accountDataAccumulator.accountDataEvents.set("m.secret_storage.default_key", { key: "key_id" });
|
||||
accountDataAccumulator.accountDataEvents.set("m.secret_storage.key.key_id", {
|
||||
key: "keykeykey",
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
});
|
||||
|
||||
// Prepare for the cross-signing keys
|
||||
const p = accountDataAccumulator.interceptSetAccountData(":type(m.cross_signing..*)");
|
||||
|
||||
await bootstrapCrossSigning(authDict);
|
||||
await p;
|
||||
|
||||
// The cross-signing keys should have been uploaded
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.master")).toBeTruthy();
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.self_signing")).toBeTruthy();
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.user_signing")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus()", () => {
|
||||
@@ -339,4 +395,48 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossSignDevice", () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// make sure that there is another device which we can sign
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
// Complete initialsync, to get the outgoing requests going
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Wait for legacy crypto to find the device
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([aliceClient.getSafeUserId()]);
|
||||
expect(devices.get(aliceClient.getSafeUserId())!.has(testData.TEST_DEVICE_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails for an unknown device", async () => {
|
||||
await expect(aliceClient.getCrypto()!.crossSignDevice("unknown")).rejects.toThrow("Unknown device");
|
||||
});
|
||||
|
||||
it("cross-signs the device", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
await aliceClient.getCrypto()!.bootstrapCrossSigning({});
|
||||
|
||||
fetchMock.mockClear();
|
||||
await aliceClient.getCrypto()!.crossSignDevice(testData.TEST_DEVICE_ID);
|
||||
|
||||
// check that a sig for the device was uploaded
|
||||
const calls = fetchMock.calls("upload-sigs");
|
||||
expect(calls.length).toEqual(1);
|
||||
const body = JSON.parse(calls[0][1]!.body as string);
|
||||
const deviceSig = body[aliceClient.getSafeUserId()][testData.TEST_DEVICE_ID];
|
||||
expect(deviceSig).toHaveProperty("signatures");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
mockSetupCrossSigningRequests,
|
||||
mockSetupMegolmBackupRequests,
|
||||
} from "../../test-utils/mockEndpoints";
|
||||
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
import { SecretStorageKeyDescription } from "../../../src/secret-storage";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
CryptoCallbacks,
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
getTestOlmAccountKeys,
|
||||
} from "./olm-utils";
|
||||
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
|
||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -339,7 +340,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
function createCryptoCallbacks(): CryptoCallbacks {
|
||||
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
|
||||
let cachedKey: { keyId: string; key: Uint8Array };
|
||||
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
|
||||
const cacheSecretStorageKey = (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => {
|
||||
cachedKey = {
|
||||
keyId,
|
||||
key,
|
||||
@@ -397,6 +398,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
|
||||
});
|
||||
|
||||
it("CryptoAPI.getOwnDeviceKeys returns plausible values", async () => {
|
||||
const deviceKeys = await aliceClient.getCrypto()!.getOwnDeviceKeys();
|
||||
// We just check for a 43-character base64 string
|
||||
expect(deviceKeys.curve25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
|
||||
expect(deviceKeys.ed25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
|
||||
});
|
||||
|
||||
it("Alice receives a megolm message", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
@@ -2425,12 +2433,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
|
||||
describe("Secret Storage and Key Backup", () => {
|
||||
/**
|
||||
* The account data events to be returned by the sync.
|
||||
* Will be updated when fecthMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
|
||||
* Will be used by `sendSyncResponseWithUpdatedAccountData`
|
||||
*/
|
||||
let accountDataEvents: Map<String, any>;
|
||||
let accountDataAccumulator: AccountDataAccumulator;
|
||||
|
||||
/**
|
||||
* Create a fake secret storage key
|
||||
@@ -2443,76 +2446,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
|
||||
beforeEach(async () => {
|
||||
createSecretStorageKey.mockClear();
|
||||
accountDataEvents = new Map();
|
||||
accountDataAccumulator = new AccountDataAccumulator();
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
});
|
||||
|
||||
function mockGetAccountData() {
|
||||
fetchMock.get(
|
||||
`path:/_matrix/client/v3/user/:userId/account_data/:type`,
|
||||
(url) => {
|
||||
const type = url.split("/").pop();
|
||||
const existing = accountDataEvents.get(type!);
|
||||
if (existing) {
|
||||
// return it
|
||||
return {
|
||||
status: 200,
|
||||
body: existing.content,
|
||||
};
|
||||
} else {
|
||||
// 404
|
||||
return {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Resolved when the cross signing key is uploaded
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const type = url.split("/").pop();
|
||||
// update account data for sync response
|
||||
accountDataEvents.set(type!, content);
|
||||
resolve(content.encrypted);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send in the sync response the current account data events, as stored by `accountDataEvents`.
|
||||
*/
|
||||
function sendSyncResponseWithUpdatedAccountData() {
|
||||
try {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: Array.from(accountDataEvents, ([type, content]) => ({
|
||||
type: type,
|
||||
content: content,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Might fail with "Cannot queue more than one /sync response" if called too often.
|
||||
// It's ok if it fails here, the sync response is cumulative and will contain
|
||||
// the latest account data.
|
||||
}
|
||||
async function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
|
||||
const content = await accountDataAccumulator.interceptSetAccountData(`m.cross_signing.${key}`);
|
||||
return content.encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2520,28 +2466,18 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
* Resolved when a key is uploaded (ie in `body.content.key`)
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// This url is called multiple times during the secret storage bootstrap process
|
||||
// When we received the newly generated key, we return it
|
||||
fetchMock.put(
|
||||
"express:/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)",
|
||||
(url: string, options: RequestInit) => {
|
||||
const type = url.split("/").pop();
|
||||
const content = JSON.parse(options.body as string);
|
||||
|
||||
// update account data for sync response
|
||||
accountDataEvents.set(type!, content);
|
||||
|
||||
if (content.key) {
|
||||
resolve(content.key);
|
||||
}
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
async function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const content = await accountDataAccumulator.interceptSetAccountData(":type(m.secret_storage.*)", {
|
||||
repeat: 1,
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
if (content.key) {
|
||||
return content.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function awaitMegolmBackupKeyUpload(): Promise<Record<string, {}>> {
|
||||
@@ -2552,7 +2488,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
// update account data for sync response
|
||||
accountDataEvents.set("m.megolm_backup.v1", content);
|
||||
accountDataAccumulator.accountDataEvents.set("m.megolm_backup.v1", content);
|
||||
resolve(content.encrypted);
|
||||
return {};
|
||||
},
|
||||
@@ -2617,7 +2553,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await bootstrapPromise;
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Finally ensure backup is working
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
@@ -2625,6 +2561,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await backupStatusUpdate;
|
||||
}
|
||||
|
||||
describe("Generate 4S recovery keys", () => {
|
||||
it("should create a random recovery key", async () => {
|
||||
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase();
|
||||
expect(generatedKey.privateKey).toBeDefined();
|
||||
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(generatedKey.privateKey.length).toBe(32);
|
||||
expect(generatedKey.keyInfo?.passphrase).toBeUndefined();
|
||||
expect(generatedKey.encodedPrivateKey).toBeDefined();
|
||||
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
|
||||
});
|
||||
|
||||
it("should create a recovery key from passphrase", async () => {
|
||||
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase("mypassphrase");
|
||||
expect(generatedKey.privateKey).toBeDefined();
|
||||
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(generatedKey.privateKey.length).toBe(32);
|
||||
expect(generatedKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2");
|
||||
expect(generatedKey.keyInfo?.passphrase?.iterations).toBe(500000);
|
||||
|
||||
expect(generatedKey.encodedPrivateKey).toBeDefined();
|
||||
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapSecretStorage", () => {
|
||||
// Doesn't work with legacy crypto, which will try to bootstrap even without private key, which is buggy.
|
||||
newBackendOnly(
|
||||
@@ -2639,7 +2599,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
);
|
||||
|
||||
it("Should create a 4S key", async () => {
|
||||
mockGetAccountData();
|
||||
accountDataAccumulator.interceptGetAccountData();
|
||||
|
||||
const awaitAccountData = awaitAccountDataUpdate("m.secret_storage.default_key");
|
||||
|
||||
@@ -2650,8 +2610,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// check that the key content contains the key check info
|
||||
const keyContent = accountDataAccumulator.accountDataEvents.get(
|
||||
`m.secret_storage.key.${secretStorageKey}`,
|
||||
)!;
|
||||
// In order to verify if the key is valid, a zero secret is encrypted with the key
|
||||
expect(keyContent.iv).toBeDefined();
|
||||
expect(keyContent.mac).toBeDefined();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
@@ -2675,7 +2643,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
@@ -2699,7 +2667,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
@@ -2713,7 +2681,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
@@ -2737,7 +2705,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponseWithUpdatedAccountData();
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
// Wait for the cross signing keys to be uploaded
|
||||
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
|
||||
@@ -2890,6 +2858,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
|
||||
const newBackupUploadPromise = awaitMegolmBackupKeyUpload();
|
||||
|
||||
// Track calls to scheduleAllGroupSessionsForBackup. This is
|
||||
// only relevant on legacy encryption.
|
||||
const scheduleAllGroupSessionsForBackup = jest.fn();
|
||||
if (backend === "libolm") {
|
||||
aliceClient.crypto!.backupManager.scheduleAllGroupSessionsForBackup =
|
||||
scheduleAllGroupSessionsForBackup;
|
||||
} else {
|
||||
// With Rust crypto, we don't need to call this function, so
|
||||
// we call the dummy value here so we pass our later
|
||||
// expectation.
|
||||
scheduleAllGroupSessionsForBackup();
|
||||
}
|
||||
|
||||
await aliceClient.getCrypto()!.resetKeyBackup();
|
||||
await awaitDeleteCalled;
|
||||
await newBackupStatusUpdate;
|
||||
@@ -2901,6 +2882,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(nextVersion).toBeDefined();
|
||||
expect(nextVersion).not.toEqual(currentVersion);
|
||||
expect(nextKey).not.toEqual(currentBackupKey);
|
||||
expect(scheduleAllGroupSessionsForBackup).toHaveBeenCalled();
|
||||
|
||||
// The `deleteKeyBackupVersion` API is deprecated but has been modified to work with both crypto backend
|
||||
// ensure that it works anyhow
|
||||
|
||||
@@ -17,8 +17,18 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
import {
|
||||
createClient,
|
||||
CryptoApi,
|
||||
CryptoEvent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
IMegolmSessionData,
|
||||
MatrixClient,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
@@ -31,9 +41,10 @@ import {
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, IDeferred } from "../../../src/utils";
|
||||
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
@@ -285,17 +296,21 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
let aliceCrypto: CryptoApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
});
|
||||
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
@@ -339,17 +354,179 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
expect(afterCache.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock backup response of a GET `room_keys/keys` with a given number of keys per room.
|
||||
* @param keysPerRoom The number of keys per room
|
||||
*/
|
||||
function createBackupDownloadResponse(keysPerRoom: number[]) {
|
||||
const response: {
|
||||
rooms: {
|
||||
[roomId: string]: {
|
||||
sessions: {
|
||||
[sessionId: string]: KeyBackupSession;
|
||||
};
|
||||
};
|
||||
};
|
||||
} = { rooms: {} };
|
||||
|
||||
const expectedTotal = keysPerRoom.reduce((a, b) => a + b, 0);
|
||||
for (let i = 0; i < keysPerRoom.length; i++) {
|
||||
const roomId = `!room${i}:example.com`;
|
||||
response.rooms[roomId] = { sessions: {} };
|
||||
for (let j = 0; j < keysPerRoom[i]; j++) {
|
||||
const sessionId = `session${j}`;
|
||||
// Put the same fake session data, not important for that test
|
||||
response.rooms[roomId].sessions[sessionId] = testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
}
|
||||
}
|
||||
return { response, expectedTotal };
|
||||
}
|
||||
|
||||
it("Should import full backup in chunks", async function () {
|
||||
const importMockImpl = jest.fn();
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = importMockImpl;
|
||||
|
||||
// We need several rooms with several sessions to test chunking
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const progressCallback = jest.fn();
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
progressCallback,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(expectedTotal);
|
||||
// Should be called 5 times: 200*4 plus one chunk with the remaining 32
|
||||
expect(importMockImpl).toHaveBeenCalledTimes(5);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(importMockImpl.mock.calls[i][0].length).toEqual(200);
|
||||
}
|
||||
expect(importMockImpl.mock.calls[4][0].length).toEqual(32);
|
||||
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
stage: "fetch",
|
||||
});
|
||||
|
||||
// Should be called 4 times and report 200/400/600/800
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: (i + 1) * 200,
|
||||
stage: "load_keys",
|
||||
failures: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// The last chunk
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 832,
|
||||
stage: "load_keys",
|
||||
failures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should continue to process backup if a chunk import fails and report failures", async function () {
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
// Fail to import first chunk
|
||||
throw new Error("test error");
|
||||
})
|
||||
// Ok for other chunks
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([100, 300]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const progressCallback = jest.fn();
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
progressCallback,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.total).toStrictEqual(expectedTotal);
|
||||
// A chunk failed to import
|
||||
expect(result.imported).toStrictEqual(200);
|
||||
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 0,
|
||||
stage: "load_keys",
|
||||
failures: 200,
|
||||
});
|
||||
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 200,
|
||||
stage: "load_keys",
|
||||
failures: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should continue if some keys fails to decrypt", async function () {
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = jest.fn();
|
||||
|
||||
const decryptionFailureCount = 2;
|
||||
|
||||
const mockDecryptor = {
|
||||
// DecryptSessions does not reject on decryption failure, but just skip the key
|
||||
decryptSessions: jest.fn().mockImplementation((sessions) => {
|
||||
// simulate fail to decrypt 2 keys out of all
|
||||
const decrypted = [];
|
||||
const keys = Object.keys(sessions);
|
||||
for (let i = 0; i < keys.length - decryptionFailureCount; i++) {
|
||||
decrypted.push({
|
||||
session_id: keys[i],
|
||||
} as unknown as Mocked<IMegolmSessionData>);
|
||||
}
|
||||
return decrypted;
|
||||
}),
|
||||
free: jest.fn(),
|
||||
};
|
||||
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.getBackupDecryptor = jest.fn().mockResolvedValue(mockDecryptor);
|
||||
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([100]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
);
|
||||
|
||||
expect(result.total).toStrictEqual(expectedTotal);
|
||||
// A chunk failed to import
|
||||
expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount);
|
||||
});
|
||||
|
||||
it("recover specific session from backup", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
@@ -370,16 +547,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
|
||||
it("Fails on bad recovery key", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
@@ -888,6 +1055,146 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backup Changed from other sessions", () => {
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
|
||||
// let aliceClient: MatrixClient;
|
||||
|
||||
const SYNC_RESPONSE = {
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
||||
};
|
||||
|
||||
it("If current backup has changed, the manager should switch to the new one on UTD", async () => {
|
||||
// =====
|
||||
// First ensure that the client checks for keys using the backup version 1
|
||||
/// =====
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
(url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == "1") {
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "1",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
|
||||
// =====
|
||||
// Second suppose now that the backup has changed to version 2
|
||||
/// =====
|
||||
|
||||
const newBackup = {
|
||||
...testData.SIGNED_BACKUP_DATA,
|
||||
version: "2",
|
||||
};
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, { overwriteRoutes: true });
|
||||
// suppose the new key is now known
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
newBackup.version,
|
||||
);
|
||||
|
||||
// A check backup should happen at some point
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
(url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == newBackup.version) {
|
||||
awaitHasQueriedNewBackup.resolve();
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
// awaitHasQueriedOldBackup.resolve();
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "2",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the new backup.
|
||||
const newMessage: Partial<IEvent> = {
|
||||
type: "m.room.encrypted",
|
||||
room_id: "!room:id",
|
||||
sender: "@alice:localhost",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext:
|
||||
"AwgAEpABKvf9FqPW52zeHfeVTn90a3jlBLlx7g6VDEkc2089RQUJoWpSJRiK13E83rN41wgGFJccyfoCr7ZDGJeuGYMGETTrgnLQhLs6JmyPf37JYkzxW8uS8rGUKEqTFQriKhibHVLvVacOlSIObUiKU/V3r176XuixqZF/4eyK9A22JNpInbgI10ZUT6LnApH9LR3FpZbE2zImf1uNPuvp7r0xQbW7CcJjqpH+qTPBD5zFdFnMkc2SnbXCsIOaX11Dm0krWfQz7iA26ZnI1nyZnyh7XPrCnJCRsuQH",
|
||||
device_id: "WVMJGTSSVB",
|
||||
sender_key: "E5RiY/YCIrHWaF4u416CqvblC6udK2jt9SJ/h1QeLS0",
|
||||
session_id: "ybnW+LGdUhoS4fHm1DAEphukO3sZ1GCqZD7UQz7L+GA",
|
||||
},
|
||||
event_id: "$event2",
|
||||
origin_server_ts: 1507753887000,
|
||||
};
|
||||
|
||||
const nextSyncResponse = {
|
||||
next_batch: 2,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [newMessage] } } } },
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse(nextSyncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitHasQueriedNewBackup.promise;
|
||||
});
|
||||
});
|
||||
|
||||
/** make sure that the client knows about the dummy device */
|
||||
async function waitForDeviceList(): Promise<void> {
|
||||
// Completing the initial sync will make the device list download outdated device lists (of which our own
|
||||
|
||||
@@ -16,8 +16,12 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { createClient } from "../../../src";
|
||||
import { createClient, CryptoEvent, IndexedDBCryptoStore } from "../../../src";
|
||||
import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -88,6 +92,68 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
await matrixClient.initRustCrypto();
|
||||
await matrixClient.initRustCrypto();
|
||||
});
|
||||
|
||||
it("should migrate from libolm", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
auth_data: {
|
||||
public_key: "q+HZiJdHl2Yopv9GGvv7EYSzDMrAiRknK4glSdoaomI",
|
||||
signatures: {
|
||||
"@vdhtest200713:matrix.org": {
|
||||
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
|
||||
"reDp6Mu+j+tfUL3/T6f5OBT3N825Lzpc43vvG+RvjX6V+KxXzodBQArgCoeEHLtL9OgSBmNrhTkSOX87MWCKAw",
|
||||
"ed25519:KMFSTJSMLB":
|
||||
"F8tyV5W6wNi0GXTdSg+gxSCULQi0EYxdAAqfkyNq58KzssZMw5i+PRA0aI2b+D7NH/aZaJrtiYNHJ0gWLSQvAw",
|
||||
},
|
||||
},
|
||||
},
|
||||
version: "7",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
etag: "1",
|
||||
count: 79,
|
||||
});
|
||||
|
||||
const testStoreName = "test-store";
|
||||
await populateStore(testStoreName);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, testStoreName);
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@vdhtest200713:matrix.org",
|
||||
deviceId: "KMFSTJSMLB",
|
||||
cryptoStore,
|
||||
pickleKey: "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o",
|
||||
});
|
||||
|
||||
const progressListener = jest.fn();
|
||||
matrixClient.addListener(CryptoEvent.LegacyCryptoStoreMigrationProgress, progressListener);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// Do some basic checks on the imported data
|
||||
const deviceKeys = await matrixClient.getCrypto()!.getOwnDeviceKeys();
|
||||
expect(deviceKeys.curve25519).toEqual("LKv0bKbc0EC4h0jknbemv3QalEkeYvuNeUXVRgVVTTU");
|
||||
expect(deviceKeys.ed25519).toEqual("qK70DEqIXq7T+UU3v/al47Ab4JkMEBLpNrTBMbS5rrw");
|
||||
|
||||
expect(await matrixClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual("7");
|
||||
|
||||
// check the progress callback
|
||||
expect(progressListener.mock.calls.length).toBeGreaterThan(50);
|
||||
|
||||
// The first call should have progress == 0
|
||||
const [firstProgress, totalSteps] = progressListener.mock.calls[0];
|
||||
expect(totalSteps).toBeGreaterThan(3000);
|
||||
expect(firstProgress).toEqual(0);
|
||||
|
||||
for (let i = 1; i < progressListener.mock.calls.length - 1; i++) {
|
||||
const [progress, total] = progressListener.mock.calls[i];
|
||||
expect(total).toEqual(totalSteps);
|
||||
expect(progress).toBeGreaterThan(progressListener.mock.calls[i - 1][0]);
|
||||
expect(progress).toBeLessThanOrEqual(totalSteps);
|
||||
}
|
||||
|
||||
// The final call should have progress == total == -1
|
||||
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("MatrixClient.clearStores", () => {
|
||||
|
||||
@@ -1259,14 +1259,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
const keyBackupIsCached = emitPromise(aliceClient, CryptoEvent.KeyBackupDecryptionKeyCached);
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
await keyBackupIsCached;
|
||||
|
||||
// the backup secret should be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { logger } from "../../src/logger";
|
||||
import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
|
||||
@@ -623,9 +623,7 @@ describe("MatrixClient event timelines", function () {
|
||||
"GET",
|
||||
"/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
@@ -1154,10 +1152,7 @@ describe("MatrixClient event timelines", function () {
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name),
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
@@ -1262,11 +1257,8 @@ describe("MatrixClient event timelines", function () {
|
||||
"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,
|
||||
}),
|
||||
)
|
||||
@@ -1321,11 +1313,7 @@ describe("MatrixClient event timelines", function () {
|
||||
function respondToThread(root: Partial<IEvent>, replies: Partial<IEvent>[]): ExpectedHttpRequest {
|
||||
const request = httpBackend.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(root.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
"?dir=b&limit=1",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(root.event_id!) + "?dir=b",
|
||||
);
|
||||
request.respond(200, function () {
|
||||
return {
|
||||
@@ -1567,7 +1555,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(threadIds).toContain(THREAD2_ROOT.event_id);
|
||||
const [allThreads] = timelineSets!;
|
||||
const timeline = allThreads.getLiveTimeline()!;
|
||||
// Test threads are in chronological order
|
||||
// Test threads are in chronological order (first thread should be first because it has a more recent reply)
|
||||
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
@@ -2034,9 +2022,7 @@ describe("MatrixClient event timelines", function () {
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
|
||||
@@ -28,32 +28,70 @@ import {
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
fixNotificationCountOnDecryption,
|
||||
} from "../../src";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { mkThread } from "../test-utils/thread";
|
||||
import { SyncState } from "../../src/sync";
|
||||
|
||||
const userA = "@alice:localhost";
|
||||
const userB = "@bob:localhost";
|
||||
const selfUserId = userA;
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
|
||||
function setupTestClient(): [MatrixClient, HttpBackend] {
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
const httpBackend = testClient.httpBackend;
|
||||
const client = testClient.client;
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
return [client, httpBackend];
|
||||
}
|
||||
|
||||
describe("Notification count fixing", () => {
|
||||
let client: MatrixClient | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
[client] = setupTestClient();
|
||||
});
|
||||
|
||||
it("doesn't increment notification count for events that can't be found in a room", async () => {
|
||||
const roomId = "!room:localhost";
|
||||
|
||||
client!.startClient({ threadSupport: true });
|
||||
const room = new Room(roomId, client!, selfUserId);
|
||||
jest.spyOn(client!, "getRoom").mockImplementation((id) => (id === roomId ? room : null));
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: roomId,
|
||||
type: "m.reaction",
|
||||
event_id: "$foo",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$foo",
|
||||
key: "x",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(event, "getPushActions").mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: {},
|
||||
});
|
||||
|
||||
fixNotificationCountOnDecryption(client!, event);
|
||||
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
const userA = "@alice:localhost";
|
||||
const userB = "@bob:localhost";
|
||||
|
||||
const selfUserId = userA;
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
|
||||
let client: MatrixClient | undefined;
|
||||
let httpBackend: HttpBackend | undefined;
|
||||
|
||||
const setupTestClient = (): [MatrixClient, HttpBackend] => {
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
const httpBackend = testClient.httpBackend;
|
||||
const client = testClient.client;
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
return [client, httpBackend];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
[client, httpBackend] = setupTestClient();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
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 { MockOptionsMethodPut } from "fetch-mock";
|
||||
|
||||
import { ISyncResponder } from "./SyncResponder";
|
||||
|
||||
/**
|
||||
* An object which intercepts `account_data` get and set requests via fetch-mock.
|
||||
*/
|
||||
export class AccountDataAccumulator {
|
||||
/**
|
||||
* The account data events to be returned by the sync.
|
||||
* Will be updated when fetchMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
|
||||
* Will be used by `sendSyncResponseWithUpdatedAccountData`
|
||||
*/
|
||||
public accountDataEvents: Map<String, any> = new Map();
|
||||
|
||||
/**
|
||||
* Intercept requests to set a particular type of account data.
|
||||
*
|
||||
* Once it is set, its data is stored (for future return by `interceptGetAccountData` etc) and the resolved promise is
|
||||
* resolved.
|
||||
*
|
||||
* @param accountDataType - type of account data to be intercepted
|
||||
* @param opts - options to pass to fetchMock
|
||||
* @returns a Promise which will resolve (with the content of the account data) once it is set.
|
||||
*/
|
||||
public interceptSetAccountData(accountDataType: string, opts?: MockOptionsMethodPut): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/${accountDataType}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const type = url.split("/").pop();
|
||||
// update account data for sync response
|
||||
this.accountDataEvents.set(type!, content);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
opts,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept all requests to get account data
|
||||
*/
|
||||
public interceptGetAccountData(): void {
|
||||
fetchMock.get(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/:type`,
|
||||
(url) => {
|
||||
const type = url.split("/").pop();
|
||||
const existing = this.accountDataEvents.get(type!);
|
||||
if (existing) {
|
||||
// return it
|
||||
return {
|
||||
status: 200,
|
||||
body: existing,
|
||||
};
|
||||
} else {
|
||||
// 404
|
||||
return {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sync response the current account data events.
|
||||
*/
|
||||
public sendSyncResponseWithUpdatedAccountData(syncResponder: ISyncResponder): void {
|
||||
try {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: Array.from(this.accountDataEvents, ([type, content]) => ({
|
||||
type: type,
|
||||
content: content,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Might fail with "Cannot queue more than one /sync response" if called too often.
|
||||
// It's ok if it fails here, the sync response is cumulative and will contain
|
||||
// the latest account data.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
## Dump of libolm indexeddb cryptostore
|
||||
|
||||
This directory contains a dump of a real indexeddb store from a session using
|
||||
libolm crypto.
|
||||
|
||||
The corresponding pickle key is `+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o`.
|
||||
|
||||
It was created by pasting the following into the browser console:
|
||||
|
||||
```javascript
|
||||
async function exportIndexedDb(name) {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const dbReq = indexedDB.open(name);
|
||||
dbReq.onerror = reject;
|
||||
dbReq.onsuccess = () => resolve(dbReq.result);
|
||||
});
|
||||
|
||||
const storeNames = db.objectStoreNames;
|
||||
const exports = {};
|
||||
for (const store of storeNames) {
|
||||
exports[store] = [];
|
||||
const txn = db.transaction(store, "readonly");
|
||||
const objectStore = txn.objectStore(store);
|
||||
await new Promise((resolve, reject) => {
|
||||
const cursorReq = objectStore.openCursor();
|
||||
cursorReq.onerror = reject;
|
||||
cursorReq.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const entry = { value: cursor.value };
|
||||
if (!objectStore.keyPath) {
|
||||
entry.key = cursor.key;
|
||||
}
|
||||
exports[store].push(entry);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
window.saveAs(
|
||||
new Blob([JSON.stringify(await exportIndexedDb("matrix-js-sdk:crypto"), null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
}),
|
||||
"dump.json",
|
||||
);
|
||||
```
|
||||
|
||||
The pickle key is extracted via `mxMatrixClientPeg.get().crypto.olmDevice.pickleKey`.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Populate an IndexedDB store with the test data from this directory.
|
||||
*
|
||||
* @param name - Name of the IndexedDB database to create.
|
||||
*/
|
||||
export async function populateStore(name: string): Promise<IDBDatabase> {
|
||||
const req = indexedDB.open(name, 11);
|
||||
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
req.onupgradeneeded = (ev): void => {
|
||||
const db = req.result;
|
||||
const oldVersion = ev.oldVersion;
|
||||
upgradeDatabase(oldVersion, db);
|
||||
};
|
||||
|
||||
req.onerror = (ev): void => {
|
||||
reject(req.error);
|
||||
};
|
||||
|
||||
req.onsuccess = (): void => {
|
||||
const db = req.result;
|
||||
resolve(db);
|
||||
};
|
||||
});
|
||||
|
||||
await importData(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Create the schema for the indexed db store */
|
||||
function upgradeDatabase(oldVersion: number, db: IDBDatabase) {
|
||||
if (oldVersion < 1) {
|
||||
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
|
||||
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
|
||||
outgoingRoomKeyRequestsStore.createIndex("state", "state");
|
||||
}
|
||||
|
||||
if (oldVersion < 2) {
|
||||
db.createObjectStore("account");
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] });
|
||||
sessionsStore.createIndex("deviceKey", "deviceKey");
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 5) {
|
||||
db.createObjectStore("device_data");
|
||||
}
|
||||
|
||||
if (oldVersion < 6) {
|
||||
db.createObjectStore("rooms");
|
||||
}
|
||||
|
||||
if (oldVersion < 7) {
|
||||
db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 8) {
|
||||
db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 9) {
|
||||
const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] });
|
||||
problemsStore.createIndex("deviceKey", "deviceKey");
|
||||
|
||||
db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 10) {
|
||||
db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 11) {
|
||||
db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] });
|
||||
}
|
||||
}
|
||||
|
||||
async function importData(db: IDBDatabase) {
|
||||
const path = resolve("spec/test-utils/test_indexeddb_cryptostore_dump/dump.json");
|
||||
const json: Record<string, Array<{ key?: any; value: any }>> = JSON.parse(
|
||||
await readFile(path, { encoding: "utf8" }),
|
||||
);
|
||||
|
||||
for (const [storeName, data] of Object.entries(json)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const store = db.transaction(storeName, "readwrite").objectStore(storeName);
|
||||
|
||||
function putEntry(idx: number) {
|
||||
if (idx >= data.length) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { key, value } = data[idx];
|
||||
try {
|
||||
const putReq = store.put(value, key);
|
||||
putReq.onsuccess = (_) => putEntry(idx + 1);
|
||||
putReq.onerror = (_) => reject(putReq.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify(
|
||||
value,
|
||||
)}: ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
putEntry(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -161,3 +161,23 @@ export const mkThread = ({
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a thread, and make sure the events are added to the thread and the
|
||||
* room's timeline as if they came in via sync.
|
||||
*
|
||||
* Note that mkThread doesn't actually add the events properly to the room.
|
||||
*/
|
||||
export const populateThread = ({
|
||||
room,
|
||||
client,
|
||||
authorId,
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): MakeThreadResult => {
|
||||
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
|
||||
ret.thread.initialEventsFetched = true;
|
||||
room.addLiveEvents(ret.events);
|
||||
return ret;
|
||||
};
|
||||
|
||||
@@ -269,7 +269,11 @@ export class MockRTCRtpTransceiver {
|
||||
}
|
||||
|
||||
export class MockMediaStreamTrack {
|
||||
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {}
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly kind: "audio" | "video",
|
||||
public enabled = true,
|
||||
) {}
|
||||
|
||||
public stop = jest.fn<void, []>();
|
||||
|
||||
@@ -306,7 +310,10 @@ export class MockMediaStreamTrack {
|
||||
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
|
||||
// implementation
|
||||
export class MockMediaStream {
|
||||
constructor(public id: string, private tracks: MockMediaStreamTrack[] = []) {}
|
||||
constructor(
|
||||
public id: string,
|
||||
private tracks: MockMediaStreamTrack[] = [],
|
||||
) {}
|
||||
|
||||
public listeners: [string, (...args: any[]) => any][] = [];
|
||||
public isStopped = false;
|
||||
@@ -435,7 +442,11 @@ type EmittedEventMap = CallEventHandlerEventHandlerMap &
|
||||
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
|
||||
public mediaHandler = new MockMediaHandler();
|
||||
|
||||
constructor(public userId: string, public deviceId: string, public sessionId: string) {
|
||||
constructor(
|
||||
public userId: string,
|
||||
public deviceId: string,
|
||||
public sessionId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -502,7 +513,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
}
|
||||
|
||||
export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
|
||||
constructor(public roomId: string, public groupCallId?: string) {
|
||||
constructor(
|
||||
public roomId: string,
|
||||
public groupCallId?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -550,7 +564,11 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
|
||||
}
|
||||
|
||||
export class MockCallFeed {
|
||||
constructor(public userId: string, public deviceId: string | undefined, public stream: MockMediaStream) {}
|
||||
constructor(
|
||||
public userId: string,
|
||||
public deviceId: string | undefined,
|
||||
public stream: MockMediaStream,
|
||||
) {}
|
||||
|
||||
public measureVolumeActivity(val: boolean) {}
|
||||
public dispose() {}
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("AutoDiscovery", function () {
|
||||
function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
not_matrix_versions: ["v1.1"],
|
||||
not_matrix_versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -388,7 +388,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -428,7 +428,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -469,7 +469,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -515,7 +515,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -560,7 +560,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -606,7 +606,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -653,7 +653,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -697,7 +697,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -747,7 +747,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -910,7 +910,7 @@ describe("AutoDiscovery", function () {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetBehavior();
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.5"] });
|
||||
|
||||
fetchMock.get("https://example.org/.well-known/matrix/client", {
|
||||
"m.homeserver": {
|
||||
|
||||
@@ -356,7 +356,6 @@ describe("Crypto", function () {
|
||||
|
||||
let crypto: Crypto;
|
||||
let mockBaseApis: MatrixClient;
|
||||
let mockRoomList: RoomList;
|
||||
|
||||
let fakeEmitter: EventEmitter;
|
||||
|
||||
@@ -390,19 +389,10 @@ describe("Crypto", function () {
|
||||
isGuest: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
mockRoomList = {} as unknown as RoomList;
|
||||
|
||||
fakeEmitter = new EventEmitter();
|
||||
|
||||
crypto = new Crypto(
|
||||
mockBaseApis,
|
||||
"@alice:home.server",
|
||||
"FLIBBLE",
|
||||
clientStore,
|
||||
cryptoStore,
|
||||
mockRoomList,
|
||||
[],
|
||||
);
|
||||
crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
|
||||
crypto.registerEventHandlers(fakeEmitter as any);
|
||||
await crypto.init();
|
||||
});
|
||||
@@ -1273,7 +1263,7 @@ describe("Crypto", function () {
|
||||
({
|
||||
init_with_private_key: jest.fn(),
|
||||
free,
|
||||
} as unknown as PkDecryption),
|
||||
}) as unknown as PkDecryption,
|
||||
);
|
||||
client.client.checkSecretStoragePrivateKey(new Uint8Array(), "");
|
||||
expect(free).toHaveBeenCalled();
|
||||
@@ -1299,7 +1289,7 @@ describe("Crypto", function () {
|
||||
({
|
||||
init_with_seed: jest.fn(),
|
||||
free,
|
||||
} as unknown as PkSigning),
|
||||
}) as unknown as PkSigning,
|
||||
);
|
||||
client.client.checkCrossSigningPrivateKey(new Uint8Array(), "");
|
||||
expect(free).toHaveBeenCalled();
|
||||
@@ -1341,15 +1331,9 @@ describe("Crypto", function () {
|
||||
setRoomEncryption: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as RoomList;
|
||||
|
||||
crypto = new Crypto(
|
||||
mockClient,
|
||||
"@alice:home.server",
|
||||
"FLIBBLE",
|
||||
clientStore,
|
||||
cryptoStore,
|
||||
mockRoomList,
|
||||
[],
|
||||
);
|
||||
crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
|
||||
// @ts-ignore we are injecting a mock into a private property
|
||||
crypto.roomList = mockRoomList;
|
||||
});
|
||||
|
||||
it("should set the algorithm if called for a known room", async () => {
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("Cross Signing", function () {
|
||||
});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T>() => ({} as T);
|
||||
alice.getAccountDataFromServer = async <T>() => ({}) as T;
|
||||
// set Alice's cross-signing key
|
||||
await alice.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (func) => {
|
||||
@@ -146,7 +146,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);
|
||||
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({}) as T;
|
||||
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
await func({});
|
||||
};
|
||||
|
||||
@@ -33,12 +33,10 @@ export async function resetCrossSigningKeys(
|
||||
|
||||
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
const storagePublicKey = decryption.generate_key();
|
||||
decryption.generate_key();
|
||||
const storagePrivateKey = decryption.get_private_key();
|
||||
decryption.free();
|
||||
return {
|
||||
// `pubkey` not used anymore with symmetric 4S
|
||||
keyInfo: { pubkey: storagePublicKey, key: undefined! },
|
||||
privateKey: storagePrivateKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,10 +190,7 @@ describe("Secrets", function () {
|
||||
};
|
||||
resetCrossSigningKeys(alice);
|
||||
|
||||
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, {
|
||||
pubkey: undefined,
|
||||
key: undefined,
|
||||
});
|
||||
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key });
|
||||
// we don't await on this because it waits for the event to come down the sync
|
||||
// which won't happen in the test setup
|
||||
alice.setDefaultSecretStorageKeyId(newKeyId);
|
||||
@@ -335,7 +332,6 @@ describe("Secrets", function () {
|
||||
|
||||
it("bootstraps when cross-signing keys in secret storage", async function () {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
const storagePublicKey = decryption.generate_key();
|
||||
const storagePrivateKey = decryption.get_private_key();
|
||||
|
||||
const bob: MatrixClient = await makeTestClient(
|
||||
@@ -378,8 +374,6 @@ describe("Secrets", function () {
|
||||
});
|
||||
await bob.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => ({
|
||||
// `pubkey` not used anymore with symmetric 4S
|
||||
keyInfo: { pubkey: storagePublicKey },
|
||||
privateKey: storagePrivateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 "fake-indexeddb/auto";
|
||||
import "jest-localstorage-mock";
|
||||
import { IndexedDBCryptoStore, LocalStorageCryptoStore, MemoryCryptoStore } from "../../../../src";
|
||||
import { CryptoStore, MigrationState, SESSION_BATCH_SIZE } from "../../../../src/crypto/store/base";
|
||||
|
||||
describe.each([
|
||||
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
|
||||
["MemoryCryptoStore", () => new MemoryCryptoStore()],
|
||||
])("CryptoStore tests for %s", function (name, dbFactory) {
|
||||
let store: CryptoStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = dbFactory();
|
||||
});
|
||||
|
||||
describe("containsData", () => {
|
||||
it("returns false at first", async () => {
|
||||
expect(await store.containsData()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after startup and account setup", async () => {
|
||||
await store.startup();
|
||||
await store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.storeAccount(txn, "not a real account");
|
||||
});
|
||||
expect(await store.containsData()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrationState", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns 0 at first", async () => {
|
||||
expect(await store.getMigrationState()).toEqual(MigrationState.NOT_STARTED);
|
||||
});
|
||||
|
||||
it("stores updates", async () => {
|
||||
await store.setMigrationState(MigrationState.INITIAL_DATA_MIGRATED);
|
||||
expect(await store.getMigrationState()).toEqual(MigrationState.INITIAL_DATA_MIGRATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get/delete EndToEndSessionsBatch", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns null at first", async () => {
|
||||
expect(await store.getEndToEndSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a batch of sessions", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 6;
|
||||
const N_SESSIONS_PER_DEVICE = 6;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
let nSessions = 0;
|
||||
await store.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
|
||||
store.countEndToEndSessions(txn, (n) => (nSessions = n)),
|
||||
);
|
||||
expect(nSessions).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Then, get a batch and check it looks right.
|
||||
const batch = await store.getEndToEndSessionsBatch();
|
||||
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
for (let i = 0; i < N_DEVICES; i++) {
|
||||
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
|
||||
const r = batch![i * N_DEVICES + j];
|
||||
|
||||
expect(r.deviceKey).toEqual(`device${i}`);
|
||||
expect(r.sessionId).toEqual(`session${j}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns another batch of sessions after the first batch is deleted", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 8;
|
||||
const N_SESSIONS_PER_DEVICE = 8;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Get the first batch
|
||||
const batch = (await store.getEndToEndSessionsBatch())!;
|
||||
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndSessionsBatch(batch);
|
||||
|
||||
// Fetch a second batch
|
||||
const batch2 = (await store.getEndToEndSessionsBatch())!;
|
||||
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndSessionsBatch(batch2);
|
||||
|
||||
// the batch should now be null.
|
||||
expect(await store.getEndToEndSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
/** Create a bunch of fake Olm sessions and stash them in the DB. */
|
||||
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_SESSIONS, (txn) => {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
store.storeEndToEndSession(
|
||||
`device${i}`,
|
||||
`session${j}`,
|
||||
{
|
||||
deviceKey: `device${i}`,
|
||||
sessionId: `session${j}`,
|
||||
},
|
||||
txn,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("get/delete EndToEndInboundGroupSessionsBatch", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns null at first", async () => {
|
||||
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a batch of sessions", async () => {
|
||||
const N_DEVICES = 6;
|
||||
const N_SESSIONS_PER_DEVICE = 6;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Mark one of the sessions as needing backup
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_BACKUP, async (txn) => {
|
||||
await store.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }], txn);
|
||||
});
|
||||
|
||||
expect(await store.countEndToEndInboundGroupSessions()).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
|
||||
const batch = await store.getEndToEndInboundGroupSessionsBatch();
|
||||
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
for (let i = 0; i < N_DEVICES; i++) {
|
||||
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
|
||||
const r = batch![i * N_DEVICES + j];
|
||||
|
||||
expect(r.senderKey).toEqual(pad43(`device${i}`));
|
||||
expect(r.sessionId).toEqual(`session${j}`);
|
||||
|
||||
// only the last session needs backup
|
||||
expect(r.needsBackup).toBe(i === 5 && j === 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns another batch of sessions after the first batch is deleted", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 8;
|
||||
const N_SESSIONS_PER_DEVICE = 8;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Get the first batch
|
||||
const batch = (await store.getEndToEndInboundGroupSessionsBatch())!;
|
||||
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndInboundGroupSessionsBatch(batch);
|
||||
|
||||
// Fetch a second batch
|
||||
const batch2 = (await store.getEndToEndInboundGroupSessionsBatch())!;
|
||||
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndInboundGroupSessionsBatch(batch2);
|
||||
|
||||
// the batch should now be null.
|
||||
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
/** Create a bunch of fake megolm sessions and stash them in the DB. */
|
||||
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, (txn) => {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
store.storeEndToEndInboundGroupSession(
|
||||
pad43(`device${i}`),
|
||||
`session${j}`,
|
||||
{
|
||||
forwardingCurve25519KeyChain: [],
|
||||
keysClaimed: {},
|
||||
room_id: "",
|
||||
session: "",
|
||||
},
|
||||
txn,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Pad a string to 43 characters long */
|
||||
function pad43(x: string): string {
|
||||
return x + ".".repeat(43 - x.length);
|
||||
}
|
||||
@@ -189,12 +189,10 @@ describe("SAS verification", function () {
|
||||
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
|
||||
bob.client.sendToDevice = async (type, map) => {
|
||||
if (type === "m.key.verification.accept") {
|
||||
macMethod = map
|
||||
.get(alice.client.getUserId()!)
|
||||
?.get(alice.client.deviceId!)?.message_authentication_code;
|
||||
keyAgreement = map
|
||||
.get(alice.client.getUserId()!)
|
||||
?.get(alice.client.deviceId!)?.key_agreement_protocol;
|
||||
macMethod = map.get(alice.client.getUserId()!)?.get(alice.client.deviceId!)
|
||||
?.message_authentication_code;
|
||||
keyAgreement = map.get(alice.client.getUserId()!)?.get(alice.client.deviceId!)
|
||||
?.key_agreement_protocol;
|
||||
}
|
||||
return origSendToDevice(type, map);
|
||||
};
|
||||
|
||||
@@ -2211,8 +2211,7 @@ describe("MatrixClient", function () {
|
||||
"org.matrix.msc3391": true,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
|
||||
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
@@ -2250,8 +2249,7 @@ describe("MatrixClient", function () {
|
||||
"org.matrix.msc3391": false,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
// populate version support
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("MSC3089Branch", () => {
|
||||
expect(eventId).toEqual(fileEventId);
|
||||
return fileEvent;
|
||||
},
|
||||
} as EventTimelineSet);
|
||||
}) as EventTimelineSet;
|
||||
client.mxcUrlToHttp = (mxc: string) => {
|
||||
expect(mxc).toEqual("mxc://" + mxcLatter);
|
||||
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
|
||||
@@ -239,7 +239,7 @@ describe("MSC3089Branch", () => {
|
||||
expect(eventId).toEqual(fileEventId);
|
||||
return fileEvent;
|
||||
},
|
||||
} as EventTimelineSet);
|
||||
}) as EventTimelineSet;
|
||||
client.mxcUrlToHttp = (mxc: string) => {
|
||||
expect(mxc).toEqual("mxc://" + mxcLatter);
|
||||
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
|
||||
@@ -332,7 +332,7 @@ describe("MSC3089Branch", () => {
|
||||
getId: () => "$unknown",
|
||||
},
|
||||
];
|
||||
staticRoom.getLiveTimeline = () => ({ getEvents: () => events } as EventTimeline);
|
||||
staticRoom.getLiveTimeline = () => ({ getEvents: () => events }) as EventTimeline;
|
||||
|
||||
directory.getFile = (evId: string) => {
|
||||
expect(evId).toEqual(fileEventId);
|
||||
|
||||
@@ -399,7 +399,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
];
|
||||
},
|
||||
};
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
|
||||
|
||||
const getFn = jest.fn().mockImplementation((roomId: string) => {
|
||||
if (roomId === thirdChildRoom) {
|
||||
@@ -422,7 +422,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
});
|
||||
|
||||
it("should find specific directories", () => {
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
|
||||
|
||||
// Only mocking used API
|
||||
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
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 { FeatureSupport, MatrixClient, MatrixEvent, ReceiptContent, THREAD_RELATION_TYPE, Thread } from "../../../src";
|
||||
import { Room } from "../../../src/models/room";
|
||||
|
||||
/**
|
||||
* Note, these tests check the functionality of the RoomReceipts class, but most
|
||||
* of them access that functionality via the surrounding Room class, because a
|
||||
* room is required for RoomReceipts to function, and this matches the pattern
|
||||
* of how this code is used in the wild.
|
||||
*/
|
||||
describe("RoomReceipts", () => {
|
||||
beforeAll(() => {
|
||||
jest.replaceProperty(Thread, "hasServerSideSupport", FeatureSupport.Stable);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports events unread if there are no receipts", () => {
|
||||
// Given there are no receipts in the room
|
||||
const room = createRoom();
|
||||
const [event] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// When I ask about any event, then it is unread
|
||||
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports events we sent as read even if there are no (real) receipts", () => {
|
||||
// Given there are no receipts in the room
|
||||
const room = createRoom();
|
||||
const [event] = createEventSentBy(readerId);
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// When I ask about an event I sent, it is read (because a synthetic
|
||||
// receipt was created and stored in RoomReceipts)
|
||||
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an unthreaded receipt for this event", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an unthreaded receipt for a later event", () => {
|
||||
// Given we have 2 events
|
||||
const room = createRoom();
|
||||
const [event1, event1Id] = createEvent();
|
||||
const [event2] = createEvent();
|
||||
room.addLiveEvents([event1, event2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createReceipt(readerId, event2));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read for a non-live event if we receive an unthreaded receipt for a live one", () => {
|
||||
// Given we have 2 events: one live and one old
|
||||
const room = createRoom();
|
||||
const [oldEvent, oldEventId] = createEvent();
|
||||
const [liveEvent] = createEvent();
|
||||
room.addLiveEvents([liveEvent]);
|
||||
createOldTimeline(room, [oldEvent]);
|
||||
|
||||
// When we receive a receipt for the live event
|
||||
room.addReceipt(createReceipt(readerId, liveEvent));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, oldEventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("compares by timestamp if two events are in separate old timelines", () => {
|
||||
// Given we have 2 events, both in old timelines, with event2 after
|
||||
// event1 in terms of timestamps
|
||||
const room = createRoom();
|
||||
const [event1, event1Id] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
event1.event.origin_server_ts = 1;
|
||||
event2.event.origin_server_ts = 2;
|
||||
createOldTimeline(room, [event1]);
|
||||
createOldTimeline(room, [event2]);
|
||||
|
||||
// When we receive a receipt for the older event
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then the earlier one is read and the later one is not
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an unthreaded receipt for an earlier event", () => {
|
||||
// Given we have 2 events
|
||||
const room = createRoom();
|
||||
const [event1] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
room.addLiveEvents([event1, event2]);
|
||||
|
||||
// When we receive a receipt for the earlier event
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then the later one is unread
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an unthreaded receipt for a different user", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for another user
|
||||
room.addReceipt(createReceipt(otherUserId, event));
|
||||
|
||||
// Then the event is still unread since the receipt was not for us
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// But it's read for the other person
|
||||
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports events we sent as read even if an earlier receipt arrives", () => {
|
||||
// Given we sent an event after some other event
|
||||
const room = createRoom();
|
||||
const [previousEvent] = createEvent();
|
||||
const [myEvent] = createEventSentBy(readerId);
|
||||
room.addLiveEvents([previousEvent, myEvent]);
|
||||
|
||||
// And I just received a receipt for the previous event
|
||||
room.addReceipt(createReceipt(readerId, previousEvent));
|
||||
|
||||
// When I ask about the event I sent, it is read (because of synthetic receipts)
|
||||
expect(room.hasUserReadEvent(readerId, myEvent.getId()!)).toBe(true);
|
||||
});
|
||||
|
||||
it("considers events after ones we sent to be unread", () => {
|
||||
// Given we sent an event, then another event came in
|
||||
const room = createRoom();
|
||||
const [myEvent] = createEventSentBy(readerId);
|
||||
const [laterEvent] = createEvent();
|
||||
room.addLiveEvents([myEvent, laterEvent]);
|
||||
|
||||
// When I ask about the later event, it is unread (because it's after the synthetic receipt)
|
||||
expect(room.hasUserReadEvent(readerId, laterEvent.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness even when receipts arrive out of order", () => {
|
||||
// Given we have 3 events
|
||||
const room = createRoom();
|
||||
const [event1] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
const [event3, event3Id] = createEvent();
|
||||
room.addLiveEvents([event1, event2, event3]);
|
||||
|
||||
// When we receive receipts for the older events out of order
|
||||
room.addReceipt(createReceipt(readerId, event2));
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then we correctly ignore the older receipt
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports read if we receive a threaded receipt for this event (main)", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, "main"));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive a threaded receipt for this event (non-main)", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event on this thread
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an threaded receipt for a later event", () => {
|
||||
// Given we have 2 events in a thread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1, event1Id] = createThreadedEvent(root);
|
||||
const [event2] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an threaded receipt for an earlier event", () => {
|
||||
// Given we have 2 events in a thread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1] = createThreadedEvent(root);
|
||||
const [event2, event2Id] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2]);
|
||||
|
||||
// When we receive a receipt for the earlier event
|
||||
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
|
||||
|
||||
// Then the later one is unread
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an threaded receipt for a different user", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for another user
|
||||
room.addReceipt(createThreadedReceipt(otherUserId, event, rootId));
|
||||
|
||||
// Then the event is still unread since the receipt was not for us
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// But it's read for the other person
|
||||
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if we receive a receipt for a later event in a different thread", () => {
|
||||
// Given 2 events exist in different threads
|
||||
const room = createRoom();
|
||||
const [root1] = createEvent();
|
||||
const [root2] = createEvent();
|
||||
const [thread1, thread1Id] = createThreadedEvent(root1);
|
||||
const [thread2] = createThreadedEvent(root2);
|
||||
setupThread(room, root1);
|
||||
setupThread(room, root2);
|
||||
room.addLiveEvents([root1, root2, thread1, thread2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread2, root2.getId()!));
|
||||
|
||||
// Then the old one is still unread since the receipt was not for this thread
|
||||
expect(room.hasUserReadEvent(readerId, thread1Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness even when threaded receipts arrive out of order", () => {
|
||||
// Given we have 3 events
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1] = createThreadedEvent(root);
|
||||
const [event2, event2Id] = createThreadedEvent(root);
|
||||
const [event3, event3Id] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2, event3]);
|
||||
|
||||
// When we receive receipts for the older events out of order
|
||||
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
|
||||
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
|
||||
|
||||
// Then we correctly ignore the older receipt
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness when mixing threaded and unthreaded receipts", () => {
|
||||
// Given we have a setup from this presentation:
|
||||
// https://docs.google.com/presentation/d/1H1gxRmRFAm8d71hCILWmpOYezsvdlb7cB6ANl-20Gns/edit?usp=sharing
|
||||
//
|
||||
// Main1----\
|
||||
// | ---Thread1a <- threaded receipt
|
||||
// | |
|
||||
// | Thread1b
|
||||
// threaded receipt -> Main2--\
|
||||
// | ----------------Thread2a <- unthreaded receipt
|
||||
// Main3 |
|
||||
// Thread2b <- threaded receipt
|
||||
//
|
||||
const room = createRoom();
|
||||
const [main1, main1Id] = createEvent();
|
||||
const [main2, main2Id] = createEvent();
|
||||
const [main3, main3Id] = createEvent();
|
||||
const [thread1a, thread1aId] = createThreadedEvent(main1);
|
||||
const [thread1b, thread1bId] = createThreadedEvent(main1);
|
||||
const [thread2a, thread2aId] = createThreadedEvent(main2);
|
||||
const [thread2b, thread2bId] = createThreadedEvent(main2);
|
||||
setupThread(room, main1);
|
||||
setupThread(room, main2);
|
||||
room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b]);
|
||||
|
||||
// And the timestamps on the events are consistent with the order above
|
||||
main1.event.origin_server_ts = 1;
|
||||
thread1a.event.origin_server_ts = 2;
|
||||
thread1b.event.origin_server_ts = 3;
|
||||
main2.event.origin_server_ts = 4;
|
||||
thread2a.event.origin_server_ts = 5;
|
||||
main3.event.origin_server_ts = 6;
|
||||
thread2b.event.origin_server_ts = 7;
|
||||
// (Note: in principle, we have the information needed to order these
|
||||
// events without using their timestamps, since they all came in via
|
||||
// addLiveEvents. In reality, some of them would have come in via the
|
||||
// /relations API, making it impossible to get the correct ordering
|
||||
// without MSC4033, which is why we fall back to timestamps. I.e. we
|
||||
// definitely could fix the code to make the above
|
||||
// timestamp-manipulation unnecessary, but it would only make this test
|
||||
// neater, not actually help in the real world.)
|
||||
|
||||
// When the receipts arrive
|
||||
room.addReceipt(createThreadedReceipt(readerId, main2, "main"));
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread1a, main1Id));
|
||||
room.addReceipt(createReceipt(readerId, thread2a));
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread2b, main2Id));
|
||||
|
||||
// Then we correctly identify that only main3 is unread
|
||||
expect(room.hasUserReadEvent(readerId, main1Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, main2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, main3Id)).toBe(false);
|
||||
expect(room.hasUserReadEvent(readerId, thread1aId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread1bId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread2aId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread2bId)).toBe(true);
|
||||
});
|
||||
|
||||
describe("dangling receipts", () => {
|
||||
it("reports unread if the unthreaded receipt is in a dangling state", () => {
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the event to the room
|
||||
// The receipt is removed from the dangling state
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// Then the event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if the threaded receipt is in a dangling state", () => {
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the events to the room
|
||||
// The receipt is removed from the dangling state
|
||||
room.addLiveEvents([root, event]);
|
||||
|
||||
// Then the event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple dangling receipts for the same event", () => {
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
// We receive another receipt in the same event for another user
|
||||
room.addReceipt(createReceipt(otherUserId, event));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the event to the room
|
||||
// The two receipts should be processed
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// Then the event is read
|
||||
// We expect that the receipt of `otherUserId` didn't replace/erase the receipt of `readerId`
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createFakeClient(): MatrixClient {
|
||||
return {
|
||||
getUserId: jest.fn(),
|
||||
getEventMapper: jest.fn().mockReturnValue(jest.fn()),
|
||||
isInitialSyncComplete: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
fetchRoomEvent: jest.fn().mockResolvedValue({}),
|
||||
paginateEventTimeline: jest.fn(),
|
||||
canSupport: { get: jest.fn() },
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
const senderId = "sender:s.ss";
|
||||
const readerId = "reader:r.rr";
|
||||
const otherUserId = "other:o.oo";
|
||||
|
||||
function createRoom(): Room {
|
||||
return new Room("!rid", createFakeClient(), "@u:s.nz", { timelineSupport: true });
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
function nextId(): string {
|
||||
return "$" + (idCounter++).toString(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event and return it and its ID.
|
||||
*/
|
||||
function createEvent(): [MatrixEvent, string] {
|
||||
return createEventSentBy(senderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event with the supplied sender and return it and its ID.
|
||||
*/
|
||||
function createEventSentBy(customSenderId: string): [MatrixEvent, string] {
|
||||
const event = new MatrixEvent({ sender: customSenderId, event_id: nextId() });
|
||||
return [event, event.getId()!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event in the thread of the supplied root and return it and its ID.
|
||||
*/
|
||||
function createThreadedEvent(root: MatrixEvent): [MatrixEvent, string] {
|
||||
const rootEventId = root.getId()!;
|
||||
const event = new MatrixEvent({
|
||||
sender: senderId,
|
||||
event_id: nextId(),
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: rootEventId,
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
["m.in_reply_to"]: {
|
||||
event_id: rootEventId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return [event, event.getId()!];
|
||||
}
|
||||
|
||||
function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEvent {
|
||||
const content: ReceiptContent = {
|
||||
[referencedEvent.getId()!]: {
|
||||
"m.read": {
|
||||
[userId]: {
|
||||
ts: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, threadId: string): MatrixEvent {
|
||||
const content: ReceiptContent = {
|
||||
[referencedEvent.getId()!]: {
|
||||
"m.read": {
|
||||
[userId]: {
|
||||
ts: 123,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeline in the timeline set that is not the live timeline.
|
||||
*/
|
||||
function createOldTimeline(room: Room, events: MatrixEvent[]) {
|
||||
const oldTimeline = room.getUnfilteredTimelineSet().addTimeline();
|
||||
room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, oldTimeline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the hacks required for this room to create a thread based on the root
|
||||
* event supplied.
|
||||
*/
|
||||
function setupThread(room: Room, root: MatrixEvent) {
|
||||
const thread = room.createThread(root.getId()!, root, [root], false);
|
||||
thread.initialEventsFetched = true;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { mocked } from "jest-mock";
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
|
||||
@@ -149,20 +149,38 @@ describe("Thread", () => {
|
||||
});
|
||||
|
||||
it("considers other events with no RR as unread", () => {
|
||||
const { thread, events } = mkThread({
|
||||
// Given a long thread exists
|
||||
const { thread, events } = populateThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: [myUserId],
|
||||
authorId: "@other:foo.com",
|
||||
participantUserIds: ["@other:foo.com"],
|
||||
length: 25,
|
||||
ts: 190,
|
||||
});
|
||||
|
||||
// Before alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy();
|
||||
const event1 = events.at(1)!;
|
||||
const event2 = events.at(2)!;
|
||||
const event24 = events.at(24)!;
|
||||
|
||||
// After alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
|
||||
// And we have read the second message in it with an unthreaded receipt
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: room.roomId,
|
||||
content: {
|
||||
// unthreaded receipt for the second message in the thread
|
||||
[event2.getId()!]: {
|
||||
[ReceiptType.Read]: {
|
||||
[myUserId]: { ts: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then we have read the first message in the thread, and not the last
|
||||
expect(thread.hasUserReadEvent(myUserId, event1.getId()!)).toBe(true);
|
||||
expect(thread.hasUserReadEvent(myUserId, event24.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("considers event as read if there's a more recent unthreaded receipt", () => {
|
||||
@@ -481,13 +499,13 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.data.ts).toEqual(100);
|
||||
expect(receipt?.eventId).toEqual(message2.getId());
|
||||
expect(receipt?.data.ts).toEqual(200);
|
||||
expect(receipt?.data.thread_id).toEqual(thread.id);
|
||||
|
||||
// (And the receipt was synthetic)
|
||||
@@ -505,14 +523,14 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
const { thread, message1 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
|
||||
|
||||
// Then no receipt was added to the thread (the receipt is still
|
||||
// for the thread root). This happens because since we have no
|
||||
// Then the receipt is for the first message, because its
|
||||
// timestamp is later. This happens because since we have no
|
||||
// recursive relations support, we know that sometimes events
|
||||
// appear out of order, so we have to check their timestamps as
|
||||
// a guess of the correct order.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,11 +548,11 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.eventId).toEqual(message2.getId());
|
||||
});
|
||||
|
||||
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
|
||||
@@ -550,22 +568,24 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread, because relations
|
||||
// recursion is available, so we trust the server to have
|
||||
// provided us with events in the right order.
|
||||
// Then a receipt was added for the last message, even though it
|
||||
// has lower ts, because relations recursion is available, so we
|
||||
// trust the server to have provided us with events in the right
|
||||
// order.
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.eventId).toEqual(message2.getId());
|
||||
});
|
||||
});
|
||||
|
||||
async function createThreadAndEvent(
|
||||
async function createThreadAnd2Events(
|
||||
client: MatrixClient,
|
||||
rootTs: number,
|
||||
eventTs: number,
|
||||
message1Ts: number,
|
||||
message2Ts: number,
|
||||
userId: string,
|
||||
): Promise<{ thread: Thread; message: MatrixEvent }> {
|
||||
): Promise<{ thread: Thread; message1: MatrixEvent; message2: MatrixEvent }> {
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread
|
||||
@@ -576,24 +596,41 @@ describe("Thread", () => {
|
||||
participantUserIds: [],
|
||||
ts: rootTs,
|
||||
});
|
||||
// Sanity: the current receipt is for the thread root
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
// Sanity: there is no read receipt on the thread yet because the
|
||||
// thread events don't get properly added to the room by mkThread.
|
||||
expect(thread.getReadReceiptForUserId(userId)).toBeNull();
|
||||
|
||||
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
|
||||
|
||||
// When we add a message that is before the latest receipt
|
||||
const message = makeThreadEvent({
|
||||
// Add a message with ts message1Ts
|
||||
const message1 = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: eventTs,
|
||||
ts: message1Ts,
|
||||
});
|
||||
await thread.addEvent(message, false, true);
|
||||
await thread.addEvent(message1, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message };
|
||||
// Sanity: the thread now has a properly-added event, so this event
|
||||
// has a synthetic receipt.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
|
||||
|
||||
// Add a message with ts message2Ts
|
||||
const message2 = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: message2Ts,
|
||||
});
|
||||
await thread.addEvent(message2, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message1, message2 };
|
||||
}
|
||||
|
||||
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
mockClient,
|
||||
);
|
||||
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
THREAD_ID = event.getId()!;
|
||||
threadEvent = mkEvent({
|
||||
type: EventType.RoomMessage,
|
||||
|
||||
@@ -43,8 +43,10 @@ const THREAD_ID = "$thread_event_id";
|
||||
const ROOM_ID = "!123:matrix.org";
|
||||
|
||||
describe("Read receipt", () => {
|
||||
let threadRoot: MatrixEvent;
|
||||
let threadEvent: MatrixEvent;
|
||||
let roomEvent: MatrixEvent;
|
||||
let editOfThreadRoot: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
@@ -57,6 +59,15 @@ describe("Read receipt", () => {
|
||||
client.isGuest = () => false;
|
||||
client.supportsThreads = () => true;
|
||||
|
||||
threadRoot = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: { body: "This is the thread root" },
|
||||
});
|
||||
threadRoot.event.event_id = THREAD_ID;
|
||||
|
||||
threadEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
@@ -82,6 +93,9 @@ describe("Read receipt", () => {
|
||||
body: "Hello from a room",
|
||||
},
|
||||
});
|
||||
|
||||
editOfThreadRoot = utils.mkEdit(threadRoot, client, "@bob:matrix.org", ROOM_ID);
|
||||
editOfThreadRoot.setThreadId(THREAD_ID);
|
||||
});
|
||||
|
||||
describe("sendReceipt", () => {
|
||||
@@ -208,6 +222,7 @@ describe("Read receipt", () => {
|
||||
it.each([
|
||||
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
|
||||
{ getEvent: () => editOfThreadRoot, destinationId: MAIN_ROOM_TIMELINE },
|
||||
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
|
||||
const event = getEvent();
|
||||
const userId = "@bob:example.org";
|
||||
|
||||
@@ -32,7 +32,10 @@ export class DummyTransport<D extends RendezvousTransportDetails, T> implements
|
||||
ready = false;
|
||||
cancelled = false;
|
||||
|
||||
constructor(private name: string, private mockDetails: D) {}
|
||||
constructor(
|
||||
private name: string,
|
||||
private mockDetails: D,
|
||||
) {}
|
||||
onCancelled?: RendezvousFailureListener;
|
||||
|
||||
details(): Promise<RendezvousTransportDetails> {
|
||||
|
||||
+64
-7
@@ -1743,13 +1743,70 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
describe("hasUserReadUpTo", function () {
|
||||
it("should acknowledge if an event has been read", function () {
|
||||
it("returns true if there is a receipt for this event (main timeline)", function () {
|
||||
const ts = 13787898424;
|
||||
room.addLiveEvents([eventToAck]);
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
|
||||
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent);
|
||||
room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent);
|
||||
expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true);
|
||||
});
|
||||
it("return false for an unknown event", function () {
|
||||
|
||||
it("returns true if there is a receipt for a later event (main timeline)", async function () {
|
||||
// Given some events exist in the room
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "1111",
|
||||
event: true,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "2222",
|
||||
event: true,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "3333",
|
||||
event: true,
|
||||
}),
|
||||
];
|
||||
await room.addLiveEvents(events);
|
||||
|
||||
// When I add a receipt for the latest one
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
|
||||
|
||||
// Then the older ones are read too
|
||||
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
|
||||
expect(room.hasUserReadEvent(userB, events[1].getId()!)).toEqual(true);
|
||||
});
|
||||
|
||||
describe("threads enabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room.client, "supportsThreads").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns true if there is an unthreaded receipt for a later event in a thread", async () => {
|
||||
// Given a thread exists in the room
|
||||
const { thread, events } = mkThread({ room, length: 3 });
|
||||
thread.initialEventsFetched = true;
|
||||
await room.addLiveEvents(events);
|
||||
|
||||
// When I add an unthreaded receipt for the latest thread message
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
|
||||
|
||||
// Then the main timeline message is read
|
||||
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false for an unknown event", function () {
|
||||
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -3258,7 +3315,7 @@ describe("Room", function () {
|
||||
return event1 === `eventId${i}` ? 1 : -1;
|
||||
},
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
} as unknown as EventTimelineSet);
|
||||
}) as unknown as EventTimelineSet;
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
|
||||
}
|
||||
@@ -3271,7 +3328,7 @@ describe("Room", function () {
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
} as unknown as EventTimelineSet);
|
||||
}) as unknown as EventTimelineSet;
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
|
||||
@@ -3291,7 +3348,7 @@ describe("Room", function () {
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
} as unknown as EventTimelineSet);
|
||||
}) as unknown as EventTimelineSet;
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
|
||||
@@ -3309,7 +3366,7 @@ describe("Room", function () {
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
} as unknown as EventTimelineSet);
|
||||
}) as unknown as EventTimelineSet;
|
||||
});
|
||||
|
||||
it("should give precedence to m.read.private", () => {
|
||||
|
||||
@@ -97,7 +97,10 @@ describe("KeyClaimManager", () => {
|
||||
await keyClaimManager.ensureSessionsForUsers(new LogSpan(logger, "test"), [u1, u2]);
|
||||
|
||||
// check that all the calls were made
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
|
||||
expect(calledWith).toEqual([u1.toString(), u2.toString()]);
|
||||
|
||||
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
|
||||
method: "POST",
|
||||
body: { k1: "v1" },
|
||||
@@ -135,7 +138,10 @@ describe("KeyClaimManager", () => {
|
||||
|
||||
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
|
||||
// call to ensureSessionsAsUsers should have completed
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
|
||||
// check that all the calls were made
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
|
||||
expect(calledWith).toEqual([u1.toString()]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(req1Resolved).toBe(false);
|
||||
@@ -147,7 +153,9 @@ describe("KeyClaimManager", () => {
|
||||
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
|
||||
|
||||
// the first request should now have completed, and we should have more calls and fetches
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith2 = olmMachine.getMissingSessions.mock.calls[1][0].map((u) => u.toString());
|
||||
expect(calledWith2).toEqual([u2.toString()]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(req1Resolved).toBe(true);
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
/*
|
||||
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 { Mocked, SpyInstance } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { defer, IDeferred } from "../../../src/utils";
|
||||
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupManager } from "../../../src/rust-crypto/backup";
|
||||
import * as TestData from "../../test-utils/test-data";
|
||||
import {
|
||||
ConnectionError,
|
||||
CryptoEvent,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IHttpOpts,
|
||||
IMegolmSessionData,
|
||||
MatrixHttpApi,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { BackupDecryptor } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
describe("PerSessionKeyBackupDownloader", () => {
|
||||
/** The downloader under test */
|
||||
let downloader: PerSessionKeyBackupDownloader;
|
||||
|
||||
const mockCipherKey: Mocked<KeyBackupSession> = {} as unknown as Mocked<KeyBackupSession>;
|
||||
|
||||
// matches the const in PerSessionKeyBackupDownloader
|
||||
const BACKOFF_TIME = 5000;
|
||||
|
||||
let mockEmitter: TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap>;
|
||||
let mockHttp: MatrixHttpApi<IHttpOpts & { onlyData: true }>;
|
||||
let mockRustBackupManager: Mocked<RustBackupManager>;
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let mockBackupDecryptor: Mocked<BackupDecryptor>;
|
||||
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
|
||||
|
||||
function expectSessionImported(roomId: string, sessionId: string) {
|
||||
const deferred = defer<void>();
|
||||
if (!expectedSession[roomId]) {
|
||||
expectedSession[roomId] = {};
|
||||
}
|
||||
expectedSession[roomId][sessionId] = deferred;
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
} as unknown as Mocked<IMegolmSessionData>;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mockEmitter = new TypedEventEmitter() as TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap>;
|
||||
|
||||
mockHttp = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
mockBackupDecryptor = {
|
||||
decryptSessions: jest.fn(),
|
||||
} as unknown as Mocked<BackupDecryptor>;
|
||||
|
||||
mockBackupDecryptor.decryptSessions.mockImplementation(async (ciphertexts) => {
|
||||
const sessionId = Object.keys(ciphertexts)[0];
|
||||
return [mockClearSession(sessionId)];
|
||||
});
|
||||
|
||||
mockRustBackupManager = {
|
||||
getActiveBackupVersion: jest.fn(),
|
||||
requestKeyBackupVersion: jest.fn(),
|
||||
importBackedUpRoomKeys: jest.fn(),
|
||||
createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor),
|
||||
on: jest.fn().mockImplementation((event, listener) => {
|
||||
mockEmitter.on(event, listener);
|
||||
}),
|
||||
off: jest.fn().mockImplementation((event, listener) => {
|
||||
mockEmitter.off(event, listener);
|
||||
}),
|
||||
} as unknown as Mocked<RustBackupManager>;
|
||||
|
||||
mockOlmMachine = {
|
||||
getBackupKeys: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
|
||||
downloader = new PerSessionKeyBackupDownloader(logger, mockOlmMachine, mockHttp, mockRustBackupManager);
|
||||
|
||||
expectedSession = {};
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
const roomId = keys[0].room_id;
|
||||
const sessionId = keys[0].session_id;
|
||||
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (deferred) {
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
expectedSession = {};
|
||||
downloader.stop();
|
||||
fetchMock.mockReset();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Given valid backup available", () => {
|
||||
beforeEach(async () => {
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue({
|
||||
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
it("Should download and import a missing key from backup", async () => {
|
||||
const awaitKeyImported = defer<void>();
|
||||
const roomId = "!roomId";
|
||||
const sessionId = "sessionId";
|
||||
const expectAPICall = new Promise<void>((resolve) => {
|
||||
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/${roomId}/${sessionId}`, (url, request) => {
|
||||
resolve();
|
||||
return TestData.CURVE25519_KEY_BACKUP_DATA;
|
||||
});
|
||||
});
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
awaitKeyImported.resolve();
|
||||
});
|
||||
mockBackupDecryptor.decryptSessions.mockResolvedValue([TestData.MEGOLM_SESSION_DATA]);
|
||||
|
||||
downloader.onDecryptionKeyMissingError(roomId, sessionId);
|
||||
|
||||
await expectAPICall;
|
||||
await awaitKeyImported.promise;
|
||||
expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should not hammer the backup if the key is requested repeatedly", async () => {
|
||||
const blockOnServerRequest = defer<void>();
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
|
||||
await blockOnServerRequest.promise;
|
||||
return [mockCipherKey];
|
||||
});
|
||||
|
||||
const awaitKey2Imported = defer<void>();
|
||||
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
if (keys[0].session_id === "sessionId2") {
|
||||
awaitKey2Imported.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const spy = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
// Call 3 times for same key
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
// Call again for a different key
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId2");
|
||||
|
||||
// Allow the first server request to complete
|
||||
blockOnServerRequest.resolve();
|
||||
|
||||
await awaitKey2Imported.promise;
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should continue to next key if current not in backup", async () => {
|
||||
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA0`, {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No backup found",
|
||||
},
|
||||
});
|
||||
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
const expectImported = expectSessionImported("!roomA", "sessionA1");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
await jest.runAllTimersAsync();
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expectImported;
|
||||
});
|
||||
|
||||
it("Should not query repeatedly for a key not in backup", async () => {
|
||||
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA0`, {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No backup found",
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const returnedPromise = spy.mock.results[0].value;
|
||||
await expect(returnedPromise).rejects.toThrow("Failed to get key from backup: MISSING_DECRYPTION_KEY");
|
||||
|
||||
// Should not query again for a key not in backup
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// advance time to retry
|
||||
jest.advanceTimersByTime(BACKOFF_TIME + 10);
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
await expect(spy.mock.results[1].value).rejects.toThrow(
|
||||
"Failed to get key from backup: MISSING_DECRYPTION_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should stop properly", async () => {
|
||||
// Simulate a call to stop while request is in flight
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const requestRoomKeyCalled = defer<void>();
|
||||
|
||||
// Mock the request to block
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
|
||||
requestRoomKeyCalled.resolve();
|
||||
await blockOnServerRequest.promise;
|
||||
return mockCipherKey;
|
||||
});
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA2");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA3");
|
||||
|
||||
await requestRoomKeyCalled.promise;
|
||||
downloader.stop();
|
||||
|
||||
blockOnServerRequest.resolve();
|
||||
|
||||
// let the first request complete
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(mockRustBackupManager.importBackedUpRoomKeys).not.toHaveBeenCalled();
|
||||
expect(
|
||||
fetchMock.calls(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`).length,
|
||||
).toStrictEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given no usable backup available", () => {
|
||||
let getConfigSpy: SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
|
||||
|
||||
// @ts-ignore access to private function
|
||||
getConfigSpy = jest.spyOn(downloader, "getOrCreateBackupConfiguration");
|
||||
});
|
||||
|
||||
it("Should not query server if no backup", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
});
|
||||
|
||||
it("Should not query server if backup not active", async () => {
|
||||
// there is a backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// but it's not trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
});
|
||||
|
||||
it("Should stop if backup key is not cached", async () => {
|
||||
// there is a backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
// it is trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
// but the key is not cached
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
});
|
||||
|
||||
it("Should stop if backup key cached as wrong version", async () => {
|
||||
// there is a backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
// it is trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
// but the cached key has the wrong version
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue({
|
||||
backupVersion: "0",
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
});
|
||||
|
||||
it("Should stop if backup key version does not match the active one", async () => {
|
||||
// there is a backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
// The sdk is out of sync, the trusted version is the old one
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue("0");
|
||||
// key for old backup cached
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue({
|
||||
backupVersion: "0",
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Backup state update", () => {
|
||||
it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => {
|
||||
// there is a backup
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// but at this point it's not trusted and we don't have the key
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey);
|
||||
|
||||
const a0Imported = expectSessionImported("!roomA", "sessionA0");
|
||||
const a1Imported = expectSessionImported("!roomA", "sessionA1");
|
||||
const b1Imported = expectSessionImported("!roomB", "sessionB1");
|
||||
const c1Imported = expectSessionImported("!roomC", "sessionC1");
|
||||
|
||||
// During initial sync several keys are requested
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
downloader.onDecryptionKeyMissingError("!roomB", "sessionB1");
|
||||
downloader.onDecryptionKeyMissingError("!roomC", "sessionC1");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
// @ts-ignore access to private property
|
||||
expect(downloader.hasConfigurationProblem).toEqual(true);
|
||||
|
||||
// Now the backup becomes trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
// And we have the key in cache
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue({
|
||||
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
|
||||
// In that case the sdk would fire a backup status update
|
||||
mockEmitter.emit(CryptoEvent.KeyBackupStatus, true);
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await a0Imported;
|
||||
await a1Imported;
|
||||
await b1Imported;
|
||||
await c1Imported;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error cases", () => {
|
||||
beforeEach(async () => {
|
||||
// there is a backup
|
||||
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
|
||||
// It's trusted
|
||||
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
|
||||
// And we have the key in cache
|
||||
mockOlmMachine.getBackupKeys.mockResolvedValue({
|
||||
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys);
|
||||
});
|
||||
|
||||
it("Should wait on rate limit error", async () => {
|
||||
// simulate rate limit error
|
||||
fetchMock.get(
|
||||
`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`,
|
||||
{
|
||||
status: 429,
|
||||
body: {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
error: "Too many requests",
|
||||
retry_after_ms: 5000,
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const keyImported = expectSessionImported("!roomA", "sessionA0");
|
||||
|
||||
// @ts-ignore
|
||||
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const rateDeferred = defer<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
async (targetRoomId: string, targetSessionId: string, configuration: any) => {
|
||||
try {
|
||||
return await originalImplementation(targetRoomId, targetSessionId, configuration);
|
||||
} catch (err: any) {
|
||||
if (err.name === "KeyDownloadRateLimitError") {
|
||||
rateDeferred.resolve();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
|
||||
await rateDeferred.promise;
|
||||
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
|
||||
await expect(keyQuerySpy.mock.results[0].value).rejects.toThrow(
|
||||
"Failed to get key from backup: rate limited",
|
||||
);
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// Advance less than the retry_after_ms
|
||||
jest.advanceTimersByTime(100);
|
||||
// let any pending callbacks in PromiseJobs run
|
||||
await Promise.resolve();
|
||||
// no additional call should have been made
|
||||
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The loop should resume after the retry_after_ms
|
||||
jest.advanceTimersByTime(5000);
|
||||
// let any pending callbacks in PromiseJobs run
|
||||
await Promise.resolve();
|
||||
|
||||
await keyImported;
|
||||
expect(keyQuerySpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("After a network error the same key is retried", async () => {
|
||||
// simulate connectivity error
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, () => {
|
||||
throw new ConnectionError("fetch failed", new Error("fetch failed"));
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
|
||||
|
||||
// @ts-ignore
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const errorDeferred = defer<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
async (targetRoomId: string, targetSessionId: string, configuration: any) => {
|
||||
try {
|
||||
return await originalImplementation(targetRoomId, targetSessionId, configuration);
|
||||
} catch (err: any) {
|
||||
if (err.name === "KeyDownloadError") {
|
||||
errorDeferred.resolve();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
const keyImported = expectSessionImported("!roomA", "sessionA0");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
await errorDeferred.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(keyQuerySpy.mock.results[0].value).rejects.toThrow(
|
||||
"Failed to get key from backup: NETWORK_ERROR",
|
||||
);
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// Advance less than the retry_after_ms
|
||||
jest.advanceTimersByTime(100);
|
||||
// let any pending callbacks in PromiseJobs run
|
||||
await Promise.resolve();
|
||||
// no additional call should have been made
|
||||
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The loop should resume after the retry_after_ms
|
||||
jest.advanceTimersByTime(BACKOFF_TIME + 100);
|
||||
await Promise.resolve();
|
||||
|
||||
await keyImported;
|
||||
});
|
||||
|
||||
it("On Unknown error on import skip the key and continue", async () => {
|
||||
const keyImported = defer<void>();
|
||||
mockRustBackupManager.importBackedUpRoomKeys
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error("Didn't work");
|
||||
})
|
||||
.mockImplementationOnce(async (sessions) => {
|
||||
const roomId = sessions[0].room_id;
|
||||
const sessionId = sessions[0].session_id;
|
||||
if (roomId === "!roomA" && sessionId === "sessionA1") {
|
||||
keyImported.resolve();
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
|
||||
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await keyImported.promise;
|
||||
|
||||
expect(keyQuerySpy).toHaveBeenCalledTimes(2);
|
||||
expect(mockRustBackupManager.importBackedUpRoomKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,169 @@
|
||||
* /
|
||||
*/
|
||||
|
||||
import { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
Curve25519PublicKey,
|
||||
Ed25519PublicKey,
|
||||
HistoryVisibility as RustHistoryVisibility,
|
||||
IdentityKeys,
|
||||
OlmMachine,
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { HistoryVisibility } from "../../../src";
|
||||
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
import { HistoryVisibility, MatrixEvent, Room, RoomMember } from "../../../src";
|
||||
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
|
||||
it.each([
|
||||
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
|
||||
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
|
||||
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
|
||||
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
|
||||
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
|
||||
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
|
||||
describe("RoomEncryptor", () => {
|
||||
describe("History Visibility", () => {
|
||||
it.each([
|
||||
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
|
||||
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
|
||||
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
|
||||
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
|
||||
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
|
||||
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoomEncryptor", () => {
|
||||
/** The room encryptor under test */
|
||||
let roomEncryptor: RoomEncryptor;
|
||||
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let mockKeyClaimManager: Mocked<KeyClaimManager>;
|
||||
let mockOutgoingRequestManager: Mocked<OutgoingRequestsManager>;
|
||||
let mockRoom: Mocked<Room>;
|
||||
|
||||
const mockRoomMember = {
|
||||
userId: "@alice:example.org",
|
||||
membership: "join",
|
||||
} as unknown as Mocked<RoomMember>;
|
||||
|
||||
function createMockEvent(text: string): Mocked<MatrixEvent> {
|
||||
return {
|
||||
getTxnId: jest.fn().mockReturnValue(""),
|
||||
getType: jest.fn().mockReturnValue("m.room.message"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
body: text,
|
||||
msgtype: "m.text",
|
||||
}),
|
||||
makeEncrypted: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as Mocked<MatrixEvent>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockOlmMachine = {
|
||||
identityKeys: {
|
||||
curve25519: {
|
||||
toBase64: jest.fn().mockReturnValue("curve25519"),
|
||||
} as unknown as Curve25519PublicKey,
|
||||
ed25519: {
|
||||
toBase64: jest.fn().mockReturnValue("ed25519"),
|
||||
} as unknown as Ed25519PublicKey,
|
||||
} as unknown as Mocked<IdentityKeys>,
|
||||
shareRoomKey: jest.fn(),
|
||||
updateTrackedUsers: jest.fn().mockResolvedValue(undefined),
|
||||
encryptRoomEvent: jest.fn().mockResolvedValue("{}"),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
|
||||
mockKeyClaimManager = {
|
||||
ensureSessionsForUsers: jest.fn(),
|
||||
} as unknown as Mocked<KeyClaimManager>;
|
||||
|
||||
mockOutgoingRequestManager = {
|
||||
doProcessOutgoingRequests: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Mocked<OutgoingRequestsManager>;
|
||||
|
||||
mockRoom = {
|
||||
roomId: "!foo:example.org",
|
||||
getJoinedMembers: jest.fn().mockReturnValue([mockRoomMember]),
|
||||
getEncryptionTargetMembers: jest.fn().mockReturnValue([mockRoomMember]),
|
||||
shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(true),
|
||||
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Invited),
|
||||
getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Mocked<Room>;
|
||||
|
||||
roomEncryptor = new RoomEncryptor(
|
||||
mockOlmMachine,
|
||||
mockKeyClaimManager,
|
||||
mockOutgoingRequestManager,
|
||||
mockRoom,
|
||||
{ algorithm: "m.megolm.v1.aes-sha2" },
|
||||
);
|
||||
});
|
||||
|
||||
it("should ensure that there is only one shareRoomKey at a time", async () => {
|
||||
const deferredShare = defer<void>();
|
||||
const insideOlmShareRoom = defer<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
|
||||
insideOlmShareRoom.resolve();
|
||||
await deferredShare.promise;
|
||||
});
|
||||
|
||||
roomEncryptor.prepareForEncryption(false);
|
||||
await insideOlmShareRoom.promise;
|
||||
|
||||
// call several times more
|
||||
roomEncryptor.prepareForEncryption(false);
|
||||
roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
|
||||
roomEncryptor.prepareForEncryption(false);
|
||||
roomEncryptor.encryptEvent(createMockEvent("World"), false);
|
||||
|
||||
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
deferredShare.resolve();
|
||||
await roomEncryptor.prepareForEncryption(false);
|
||||
|
||||
// should have been called again
|
||||
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/element-hq/element-web/issues/26684
|
||||
it("Should maintain order of encryption requests", async () => {
|
||||
const firstTargetMembers = defer<void>();
|
||||
const secondTargetMembers = defer<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockResolvedValue(undefined);
|
||||
|
||||
// Hook into this method to demonstrate the race condition
|
||||
mockRoom.getEncryptionTargetMembers
|
||||
.mockImplementationOnce(async () => {
|
||||
await firstTargetMembers.promise;
|
||||
return [mockRoomMember];
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
await secondTargetMembers.promise;
|
||||
return [mockRoomMember];
|
||||
});
|
||||
|
||||
let firstMessageFinished: string | null = null;
|
||||
|
||||
const firstRequest = roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
|
||||
const secondRequest = roomEncryptor.encryptEvent(createMockEvent("Edit of Hello"), false);
|
||||
|
||||
firstRequest.then(() => {
|
||||
if (firstMessageFinished === null) {
|
||||
firstMessageFinished = "hello";
|
||||
}
|
||||
});
|
||||
|
||||
secondRequest.then(() => {
|
||||
if (firstMessageFinished === null) {
|
||||
firstMessageFinished = "edit";
|
||||
}
|
||||
});
|
||||
|
||||
// suppose the second getEncryptionTargetMembers call returns first
|
||||
secondTargetMembers.resolve();
|
||||
firstTargetMembers.resolve();
|
||||
|
||||
await Promise.all([firstRequest, secondRequest]);
|
||||
|
||||
expect(firstMessageFinished).toBe("hello");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { CryptoEvent, HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, TypedEventEmitter } from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import * as TestData from "../../test-utils/test-data";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { RustBackupManager } from "../../../src/rust-crypto/backup";
|
||||
|
||||
describe("Upload keys to backup", () => {
|
||||
/** The backup manager under test */
|
||||
let rustBackupManager: RustBackupManager;
|
||||
|
||||
let mockOlmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
const httpAPi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
let idGenerator = 0;
|
||||
function mockBackupRequest(keyCount: number): RustSdkCryptoJs.KeysBackupRequest {
|
||||
const requestBody: IKeyBackup = {
|
||||
rooms: {
|
||||
"!room1:server": {
|
||||
sessions: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
for (let i = 0; i < keyCount; i++) {
|
||||
requestBody.rooms["!room1:server"].sessions["session" + i] = {} as IKeyBackupSession;
|
||||
}
|
||||
return {
|
||||
id: "id" + idGenerator++,
|
||||
body: JSON.stringify(requestBody),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.KeysBackupRequest>;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
idGenerator = 0;
|
||||
|
||||
mockOlmMachine = {
|
||||
getBackupKeys: jest.fn().mockResolvedValue({
|
||||
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
|
||||
} as unknown as RustSdkCryptoJs.BackupKeys),
|
||||
backupRoomKeys: jest.fn(),
|
||||
isBackupEnabled: jest.fn().mockResolvedValue(true),
|
||||
enableBackupV1: jest.fn(),
|
||||
verifyBackup: jest.fn().mockResolvedValue({
|
||||
trusted: jest.fn().mockResolvedValue(true),
|
||||
} as unknown as RustSdkCryptoJs.SignatureVerification),
|
||||
roomKeyCounts: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
outgoingRequestProcessor = {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
jest.useRealTimers();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Should call expensive roomKeyCounts only once per loop", async () => {
|
||||
const remainingEmitted: number[] = [];
|
||||
|
||||
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
|
||||
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
|
||||
remainingEmitted.push(count);
|
||||
if (count == 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// We want several batch of keys to check that we don't call expensive room key count several times
|
||||
mockOlmMachine.backupRoomKeys
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(100))
|
||||
.mockResolvedValueOnce(mockBackupRequest(2))
|
||||
.mockResolvedValue(null);
|
||||
|
||||
mockOlmMachine.roomKeyCounts.mockResolvedValue({
|
||||
total: 602,
|
||||
// First iteration won't call roomKeyCounts(); it will be called on the second iteration after 200 keys have been saved.
|
||||
backedUp: 200,
|
||||
});
|
||||
|
||||
await rustBackupManager.checkKeyBackupAndEnable(false);
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await zeroRemainingWasEmitted;
|
||||
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(7);
|
||||
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(1);
|
||||
|
||||
// check event emission
|
||||
expect(remainingEmitted[0]).toEqual(402);
|
||||
expect(remainingEmitted[1]).toEqual(302);
|
||||
expect(remainingEmitted[2]).toEqual(202);
|
||||
expect(remainingEmitted[3]).toEqual(102);
|
||||
expect(remainingEmitted[4]).toEqual(2);
|
||||
expect(remainingEmitted[5]).toEqual(0);
|
||||
});
|
||||
|
||||
it("Should not call expensive roomKeyCounts when only one iteration is needed", async () => {
|
||||
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
|
||||
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
|
||||
if (count == 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only returns 2 keys on the first call, then none.
|
||||
mockOlmMachine.backupRoomKeys.mockResolvedValueOnce(mockBackupRequest(2)).mockResolvedValue(null);
|
||||
|
||||
await rustBackupManager.checkKeyBackupAndEnable(false);
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await zeroRemainingWasEmitted;
|
||||
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,15 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
BaseMigrationData,
|
||||
KeysQueryRequest,
|
||||
Migration,
|
||||
OlmMachine,
|
||||
PickledInboundGroupSession,
|
||||
PickledSession,
|
||||
StoreHandle,
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
@@ -25,6 +33,7 @@ import {
|
||||
CryptoEvent,
|
||||
Device,
|
||||
DeviceVerification,
|
||||
encodeBase64,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IHttpOpts,
|
||||
@@ -32,13 +41,20 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixHttpApi,
|
||||
MemoryCryptoStore,
|
||||
TypedEventEmitter,
|
||||
} 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";
|
||||
import {
|
||||
AccountDataClient,
|
||||
AddSecretStorageKeyOpts,
|
||||
SecretStorageCallbacks,
|
||||
ServerSideSecretStorage,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "../../../src/secret-storage";
|
||||
import {
|
||||
CryptoCallbacks,
|
||||
EventShieldColour,
|
||||
@@ -51,6 +67,10 @@ import * as testData from "../../test-utils/test-data";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { ClientEvent, ClientEventHandlerMap } from "../../../src/client";
|
||||
import { Curve25519AuthData } from "../../../src/crypto-api/keybackup";
|
||||
import { encryptAES } from "../../../src/crypto/aes";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "../../../src/crypto/store/base";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
@@ -65,71 +85,235 @@ describe("initRustCrypto", () => {
|
||||
return {
|
||||
registerRoomKeyUpdatedCallback: jest.fn(),
|
||||
registerUserIdentityUpdatedCallback: jest.fn(),
|
||||
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
|
||||
getSecretsFromInbox: jest.fn().mockResolvedValue([]),
|
||||
deleteSecretsFromInbox: jest.fn(),
|
||||
registerReceiveSecretCallback: jest.fn(),
|
||||
outgoingRequests: jest.fn(),
|
||||
isBackupEnabled: jest.fn().mockResolvedValue(false),
|
||||
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(false) }),
|
||||
getBackupKeys: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
|
||||
it("passes through the store params", async () => {
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: "storePrefix",
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(OlmMachine.initialize).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase if storePrefix is unset", async () => {
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
null,
|
||||
"storePassphrase",
|
||||
);
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: null,
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith(undefined, undefined);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
});
|
||||
|
||||
it("Should get secrets from inbox on start", async () => {
|
||||
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
await initRustCrypto(
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: "storePrefix",
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
|
||||
});
|
||||
|
||||
describe("libolm migration", () => {
|
||||
it("migrates data from a legacy crypto store", async () => {
|
||||
const PICKLE_KEY = "pickle1234";
|
||||
const legacyStore = new MemoryCryptoStore();
|
||||
|
||||
// Populate the legacy store with some test data
|
||||
const storeSecretKey = (type: string, key: string) =>
|
||||
encryptAndStoreSecretKey(type, new TextEncoder().encode(key), PICKLE_KEY, legacyStore);
|
||||
|
||||
await legacyStore.storeAccount({}, "not a real account");
|
||||
await storeSecretKey("m.megolm_backup.v1", "backup key");
|
||||
await storeSecretKey("master", "master key");
|
||||
await storeSecretKey("self_signing", "ssk");
|
||||
await storeSecretKey("user_signing", "usk");
|
||||
const nDevices = 6;
|
||||
const nSessionsPerDevice = 10;
|
||||
createSessions(legacyStore, nDevices, nSessionsPerDevice);
|
||||
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
|
||||
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
|
||||
|
||||
// Stub out a bunch of stuff in the Rust library
|
||||
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
|
||||
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
|
||||
|
||||
jest.spyOn(Migration, "migrateBaseData").mockResolvedValue(undefined);
|
||||
jest.spyOn(Migration, "migrateOlmSessions").mockResolvedValue(undefined);
|
||||
jest.spyOn(Migration, "migrateMegolmSessions").mockResolvedValue(undefined);
|
||||
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
secretStorage: {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: {} as CryptoCallbacks,
|
||||
storePrefix: "storePrefix",
|
||||
storePassphrase: "storePassphrase",
|
||||
legacyCryptoStore: legacyStore,
|
||||
legacyPickleKey: PICKLE_KEY,
|
||||
legacyMigrationProgressListener,
|
||||
});
|
||||
|
||||
// Check that the migration functions were correctly called
|
||||
expect(Migration.migrateBaseData).toHaveBeenCalledWith(
|
||||
expect.any(BaseMigrationData),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
);
|
||||
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
|
||||
expect(data.pickledAccount).toEqual("not a real account");
|
||||
expect(data.userId!.toString()).toEqual(TEST_USER);
|
||||
expect(data.deviceId!.toString()).toEqual(TEST_DEVICE_ID);
|
||||
expect(atob(data.backupRecoveryKey!)).toEqual("backup key");
|
||||
expect(data.backupVersion).toEqual("45");
|
||||
expect(atob(data.privateCrossSigningMasterKey!)).toEqual("master key");
|
||||
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
|
||||
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
|
||||
|
||||
expect(Migration.migrateOlmSessions).toHaveBeenCalledTimes(2);
|
||||
expect(Migration.migrateOlmSessions).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
|
||||
expect(sessions1.length).toEqual(50);
|
||||
const sessions2: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[1][0];
|
||||
expect(sessions2.length).toEqual(10);
|
||||
const sessions = [...sessions1, ...sessions2];
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
const session = sessions[i * nSessionsPerDevice + j];
|
||||
expect(session.senderKey).toEqual(`device${i}`);
|
||||
expect(session.pickle).toEqual(`session${i}.${j}`);
|
||||
expect(session.creationTime).toEqual(new Date(1000));
|
||||
expect(session.lastUseTime).toEqual(new Date(1000));
|
||||
}
|
||||
}
|
||||
|
||||
expect(Migration.migrateMegolmSessions).toHaveBeenCalledTimes(2);
|
||||
expect(Migration.migrateMegolmSessions).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
.calls[0][0];
|
||||
expect(megolmSessions1.length).toEqual(50);
|
||||
const megolmSessions2: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
.calls[1][0];
|
||||
expect(megolmSessions2.length).toEqual(10);
|
||||
const megolmSessions = [...megolmSessions1, ...megolmSessions2];
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
const session = megolmSessions[i * nSessionsPerDevice + j];
|
||||
expect(session.senderKey).toEqual(pad43(`device${i}`));
|
||||
expect(session.pickle).toEqual("sessionPickle");
|
||||
expect(session.roomId!.toString()).toEqual("!room:id");
|
||||
// only one of the sessions needs backing up
|
||||
expect(session.backedUp).toEqual(i !== 5 || j !== 5);
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) {
|
||||
const encryptedKey = await encryptAES(encodeBase64(key), Buffer.from(pickleKey), type);
|
||||
store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey);
|
||||
}
|
||||
|
||||
/** Create a bunch of fake Olm sessions and stash them in the DB. */
|
||||
function createSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
const sessionData = {
|
||||
deviceKey: `device${i}`,
|
||||
sessionId: `session${j}`,
|
||||
session: `session${i}.${j}`,
|
||||
lastReceivedMessageTs: 1000,
|
||||
};
|
||||
store.storeEndToEndSession(`device${i}`, `session${j}`, sessionData, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a bunch of fake Megolm sessions and stash them in the DB. */
|
||||
function createMegolmSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
store.storeEndToEndInboundGroupSession(
|
||||
pad43(`device${i}`),
|
||||
`session${j}`,
|
||||
{
|
||||
forwardingCurve25519KeyChain: [],
|
||||
keysClaimed: { ed25519: "sender_signing_key" },
|
||||
room_id: "!room:id",
|
||||
session: "sessionPickle",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
@@ -157,7 +341,7 @@ describe("RustCrypto", () => {
|
||||
let importTotal = 0;
|
||||
const opt: ImportRoomKeysOpts = {
|
||||
progressCallback: (stage) => {
|
||||
importTotal = stage.total;
|
||||
importTotal = stage.total ?? 0;
|
||||
},
|
||||
};
|
||||
await rustCrypto.importRoomKeys(someRoomKeys, opt);
|
||||
@@ -293,6 +477,62 @@ describe("RustCrypto", () => {
|
||||
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("bootstrapSecretStorage creates new backup when requested", async () => {
|
||||
const secretStorageCallbacks = {
|
||||
getSecretStorageKey: async (keys: any, name: string) => {
|
||||
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||
},
|
||||
} as SecretStorageCallbacks;
|
||||
const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks);
|
||||
|
||||
const outgoingRequestProcessor = {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
undefined,
|
||||
secretStorage,
|
||||
);
|
||||
|
||||
rustCrypto["checkKeyBackupAndEnable"] = async () => {
|
||||
return null;
|
||||
};
|
||||
(rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
const resetKeyBackup = (rustCrypto["resetKeyBackup"] = jest.fn());
|
||||
|
||||
async function createSecretStorageKey() {
|
||||
return {
|
||||
keyInfo: {} as AddSecretStorageKeyOpts,
|
||||
privateKey: new Uint8Array(32),
|
||||
};
|
||||
}
|
||||
|
||||
// create initial secret storage
|
||||
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
await rustCrypto.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
setupNewSecretStorage: true,
|
||||
setupNewKeyBackup: true,
|
||||
});
|
||||
// check that rustCrypto.resetKeyBackup was called
|
||||
expect(resetKeyBackup.mock.calls).toHaveLength(1);
|
||||
|
||||
// reset secret storage
|
||||
await rustCrypto.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
setupNewSecretStorage: true,
|
||||
setupNewKeyBackup: true,
|
||||
});
|
||||
// check that rustCrypto.resetKeyBackup was called again
|
||||
expect(resetKeyBackup.mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
const mockSecretStorage = {
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue(null),
|
||||
@@ -420,7 +660,7 @@ describe("RustCrypto", () => {
|
||||
decryptEvent: () =>
|
||||
({
|
||||
senderCurve25519Key: "1234",
|
||||
} as IEventDecryptionResult),
|
||||
}) as IEventDecryptionResult,
|
||||
} as unknown as CryptoBackend;
|
||||
await event.attemptDecryption(mockCryptoBackend);
|
||||
|
||||
@@ -460,7 +700,7 @@ describe("RustCrypto", () => {
|
||||
decryptEvent: () =>
|
||||
({
|
||||
clearEvent: { content: { body: "1234" } },
|
||||
} as unknown as IEventDecryptionResult),
|
||||
}) as unknown as IEventDecryptionResult,
|
||||
} as unknown as CryptoBackend;
|
||||
await encryptedEvent.attemptDecryption(mockCryptoBackend);
|
||||
return encryptedEvent;
|
||||
@@ -738,8 +978,8 @@ describe("RustCrypto", () => {
|
||||
// Expect the private key to be an Uint8Array with a length of 32
|
||||
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(recoveryKey.privateKey.length).toBe(32);
|
||||
// Expect keyInfo to be empty
|
||||
expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0);
|
||||
// Expect passphrase info to be absent
|
||||
expect(recoveryKey.keyInfo?.passphrase).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create a recovery key with password", async () => {
|
||||
@@ -759,11 +999,6 @@ describe("RustCrypto", () => {
|
||||
it("should wait for a keys/query before returning devices", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
@@ -773,7 +1008,7 @@ describe("RustCrypto", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID);
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), testData.TEST_USER_ID);
|
||||
|
||||
// an attempt to fetch the device list should block
|
||||
const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
|
||||
@@ -899,12 +1134,6 @@ describe("RustCrypto", () => {
|
||||
// Return the key backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
const olmMachine = {
|
||||
getIdentity: jest.fn(),
|
||||
// Force the backup to be trusted by the olmMachine
|
||||
@@ -917,7 +1146,7 @@ describe("RustCrypto", () => {
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
mockHttpApi,
|
||||
makeMatrixHttpApi(),
|
||||
testData.TEST_USER_ID,
|
||||
testData.TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
@@ -931,9 +1160,60 @@ describe("RustCrypto", () => {
|
||||
await rustCrypto.onUserIdentityUpdated(new RustSdkCryptoJs.UserId(testData.TEST_USER_ID));
|
||||
expect(await keyBackupStatusPromise).toBe(true);
|
||||
});
|
||||
|
||||
it("does not back up keys that came from backup", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
|
||||
await olmMachine.enableBackupV1(
|
||||
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// we import two keys: one "from backup", and one "from export"
|
||||
const [backedUpRoomKey, exportedRoomKey] = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey]);
|
||||
await rustCrypto.importRoomKeys([exportedRoomKey]);
|
||||
|
||||
// we ask for the keys that should be backed up
|
||||
const roomKeysRequest = await olmMachine.backupRoomKeys();
|
||||
expect(roomKeysRequest).toBeTruthy();
|
||||
const roomKeys = JSON.parse(roomKeysRequest!.body);
|
||||
|
||||
// we expect that the key "from export" is present
|
||||
expect(roomKeys).toMatchObject({
|
||||
rooms: {
|
||||
[exportedRoomKey.room_id]: {
|
||||
sessions: {
|
||||
[exportedRoomKey.session_id]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// we expect that the key "from backup" is not present
|
||||
expect(roomKeys).not.toMatchObject({
|
||||
rooms: {
|
||||
[backedUpRoomKey.room_id]: {
|
||||
sessions: {
|
||||
[backedUpRoomKey.session_id]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
function makeMatrixHttpApi(): MatrixHttpApi<IHttpOpts & { onlyData: true }> {
|
||||
return new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
*
|
||||
* just provides default arguments for initRustCrypto()
|
||||
@@ -945,5 +1225,54 @@ async function makeTestRustCrypto(
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
|
||||
return await initRustCrypto({
|
||||
logger,
|
||||
http,
|
||||
userId,
|
||||
deviceId,
|
||||
secretStorage,
|
||||
cryptoCallbacks,
|
||||
storePrefix: null,
|
||||
storePassphrase: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** emulate account data, storing in memory
|
||||
*/
|
||||
class DummyAccountDataClient
|
||||
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
|
||||
implements AccountDataClient
|
||||
{
|
||||
private storage: Map<string, any> = new Map();
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public async getAccountDataFromServer<T extends Record<string, any>>(eventType: string): Promise<T | null> {
|
||||
const ret = this.storage.get(eventType);
|
||||
|
||||
if (eventType) {
|
||||
return ret as T;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async setAccountData(eventType: string, content: any): Promise<{}> {
|
||||
this.storage.set(eventType, content);
|
||||
this.emit(
|
||||
ClientEvent.AccountData,
|
||||
new MatrixEvent({
|
||||
content,
|
||||
type: eventType,
|
||||
}),
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Pad a string to 43 characters long */
|
||||
function pad43(x: string): string {
|
||||
return x + ".".repeat(43 - x.length);
|
||||
}
|
||||
|
||||
@@ -62,9 +62,13 @@ describe("VerificationRequest", () => {
|
||||
|
||||
describe("startVerification", () => {
|
||||
let request: RustVerificationRequest;
|
||||
let machine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
let inner: Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
|
||||
beforeEach(() => {
|
||||
request = makeTestRequest();
|
||||
inner = makeMockedInner();
|
||||
machine = { getDevice: jest.fn() } as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
request = makeTestRequest(inner, machine);
|
||||
});
|
||||
|
||||
it("does not permit methods other than SAS", async () => {
|
||||
@@ -73,7 +77,15 @@ describe("VerificationRequest", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("raises an error if the other device is unknown", async () => {
|
||||
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
|
||||
"startVerification(): other device is unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("raises an error if starting verification does not produce a verifier", async () => {
|
||||
jest.spyOn(inner, "otherDeviceId", "get").mockReturnValue(new RustSdkCryptoJs.DeviceId("other_device"));
|
||||
machine.getDevice.mockResolvedValue({} as RustSdkCryptoJs.Device);
|
||||
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
|
||||
"Still no verifier after startSas() call",
|
||||
);
|
||||
@@ -118,11 +130,13 @@ describe("isVerificationEvent", () => {
|
||||
/** build a RustVerificationRequest with default parameters */
|
||||
function makeTestRequest(
|
||||
inner?: RustSdkCryptoJs.VerificationRequest,
|
||||
olmMachine?: RustSdkCryptoJs.OlmMachine,
|
||||
outgoingRequestProcessor?: OutgoingRequestProcessor,
|
||||
): RustVerificationRequest {
|
||||
inner ??= makeMockedInner();
|
||||
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(inner, outgoingRequestProcessor, []);
|
||||
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
@@ -133,5 +147,8 @@ function makeMockedInner(): Mocked<RustSdkCryptoJs.VerificationRequest> {
|
||||
phase: jest.fn().mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Created),
|
||||
isPassive: jest.fn().mockReturnValue(false),
|
||||
timeRemainingMillis: jest.fn(),
|
||||
get otherDeviceId() {
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ describe("ServerSideSecretStorageImpl", 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");
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
|
||||
key: new Uint8Array(32),
|
||||
});
|
||||
|
||||
// it should have made up a 32-character key id
|
||||
expect(result.keyId.length).toEqual(32);
|
||||
@@ -46,7 +48,13 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
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");
|
||||
const result = await secretStorage.addKey(
|
||||
"m.secret_storage.v1.aes-hmac-sha2",
|
||||
{
|
||||
key: new Uint8Array(32),
|
||||
},
|
||||
"myKeyId",
|
||||
);
|
||||
|
||||
// it should have made up a 32-character key id
|
||||
expect(result.keyId).toEqual("myKeyId");
|
||||
@@ -59,7 +67,10 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
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" });
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
|
||||
name: "mykey",
|
||||
key: new Uint8Array(32),
|
||||
});
|
||||
|
||||
expect(result.keyInfo.name).toEqual("mykey");
|
||||
|
||||
@@ -80,6 +91,7 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
};
|
||||
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
|
||||
passphrase,
|
||||
key: new Uint8Array(32),
|
||||
});
|
||||
|
||||
expect(result.keyInfo.passphrase).toEqual(passphrase);
|
||||
@@ -93,7 +105,9 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
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");
|
||||
await expect(() => secretStorage.addKey("bad_alg", { key: new Uint8Array(32) })).rejects.toThrow(
|
||||
"Unknown key algorithm",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 { IEvent } from "../../src";
|
||||
import { randomString } from "../../src/randomstring";
|
||||
import { getRelationsThreadFilter } from "../../src/thread-utils";
|
||||
|
||||
function makeEvent(relatesToEvent: string, relType: string): Partial<IEvent> {
|
||||
return {
|
||||
event_id: randomString(10),
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"msgtype": "m.text",
|
||||
"body": "foo",
|
||||
"m.relates_to": {
|
||||
rel_type: relType,
|
||||
event_id: relatesToEvent,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getRelationsThreadFilter", () => {
|
||||
it("should filter out relations directly to the thread root event", () => {
|
||||
const threadId = "thisIsMyThreadRoot";
|
||||
|
||||
const reactionToRoot = makeEvent(threadId, "m.annotation");
|
||||
const editToRoot = makeEvent(threadId, "m.replace");
|
||||
const firstThreadedReply = makeEvent(threadId, "m.thread");
|
||||
const reactionToThreadedEvent = makeEvent(firstThreadedReply.event_id!, "m.annotation");
|
||||
|
||||
const filteredEvents = [reactionToRoot, editToRoot, firstThreadedReply, reactionToThreadedEvent].filter(
|
||||
getRelationsThreadFilter(threadId),
|
||||
);
|
||||
|
||||
expect(filteredEvents).toEqual([firstThreadedReply, reactionToThreadedEvent]);
|
||||
});
|
||||
});
|
||||
@@ -954,7 +954,7 @@ describe("Group Call", function () {
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as MatrixEvent);
|
||||
}) as MatrixEvent;
|
||||
|
||||
it("should mute remote feed's audio after receiving metadata with video audio", async () => {
|
||||
const metadataEvent = getMetadataEvent(true, false);
|
||||
@@ -965,7 +965,7 @@ describe("Group Call", function () {
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
||||
// @ts-ignore Mock
|
||||
call.pushRemoteFeed(
|
||||
// @ts-ignore Mock
|
||||
@@ -992,7 +992,7 @@ describe("Group Call", function () {
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
||||
// @ts-ignore Mock
|
||||
call.pushRemoteFeed(
|
||||
// @ts-ignore Mock
|
||||
@@ -1310,7 +1310,7 @@ describe("Group Call", function () {
|
||||
|
||||
// @ts-ignore
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
|
||||
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
||||
call.onNegotiateReceived({
|
||||
getContent: () => ({
|
||||
[SDPStreamMetadataKey]: {
|
||||
|
||||
@@ -26,7 +26,10 @@ export class NamespacedValue<S extends string, U extends string> {
|
||||
public constructor(stable: S, unstable: U);
|
||||
public constructor(stable: S, unstable?: U);
|
||||
public constructor(stable: null | undefined, unstable: U);
|
||||
public constructor(public readonly stable?: S | null, public readonly unstable?: U) {
|
||||
public constructor(
|
||||
public readonly stable?: S | null,
|
||||
public readonly unstable?: U,
|
||||
) {
|
||||
if (!this.unstable && !this.stable) {
|
||||
throw new Error("One of stable or unstable values must be supplied");
|
||||
}
|
||||
|
||||
+23
-7
@@ -28,7 +28,7 @@ import {
|
||||
validateWellKnownAuthentication,
|
||||
} from "./oidc/validate";
|
||||
import { OidcError } from "./oidc/error";
|
||||
import { MINIMUM_MATRIX_VERSION } from "./version-support";
|
||||
import { SUPPORTED_MATRIX_VERSIONS } from "./version-support";
|
||||
|
||||
// Dev note: Auto discovery is part of the spec.
|
||||
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
|
||||
@@ -51,7 +51,10 @@ export enum AutoDiscoveryError {
|
||||
InvalidIs = "Invalid identity server discovery response",
|
||||
MissingWellknown = "No .well-known JSON file found",
|
||||
InvalidJson = "Invalid JSON",
|
||||
HomeserverTooOld = "The homeserver does not meet the minimum version requirements",
|
||||
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
|
||||
|
||||
/** @deprecated Replaced by `UnsupportedHomeserverSpecVersion` */
|
||||
HomeserverTooOld = UnsupportedHomeserverSpecVersion,
|
||||
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
|
||||
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
|
||||
}
|
||||
@@ -112,7 +115,11 @@ export class AutoDiscovery {
|
||||
|
||||
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
|
||||
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
|
||||
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
|
||||
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
|
||||
|
||||
/** @deprecated Replaced by ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION */
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
|
||||
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
|
||||
|
||||
@@ -216,10 +223,19 @@ export class AutoDiscovery {
|
||||
return Promise.resolve(clientConfig);
|
||||
}
|
||||
|
||||
// Step 3.1: Non-spec check to ensure the server will actually work for us
|
||||
if (!hsVersions.raw!["versions"].includes(MINIMUM_MATRIX_VERSION)) {
|
||||
logger.error("Homeserver does not meet minimum version requirements");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_HOMESERVER_TOO_OLD;
|
||||
// Step 3.1: Non-spec check to ensure the server will actually work for us. We need to check if
|
||||
// any of the versions in `SUPPORTED_MATRIX_VERSIONS` are listed in the /versions response.
|
||||
const hsVersionSet = new Set(hsVersions.raw!["versions"]);
|
||||
let supportedVersionFound = false;
|
||||
for (const version of SUPPORTED_MATRIX_VERSIONS) {
|
||||
if (hsVersionSet.has(version)) {
|
||||
supportedVersionFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!supportedVersionFound) {
|
||||
logger.error("Homeserver does not meet version requirements");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
|
||||
|
||||
// Supply the base_url to the caller because they may be ignoring liveliness
|
||||
// errors, like this one.
|
||||
|
||||
+208
-73
@@ -51,7 +51,7 @@ import { decodeBase64, encodeBase64 } from "./base64";
|
||||
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
|
||||
import { IOlmDevice } from "./crypto/algorithms/megolm";
|
||||
import { TypedReEmitter } from "./ReEmitter";
|
||||
import { IRoomEncryption, RoomList } from "./crypto/RoomList";
|
||||
import { IRoomEncryption } from "./crypto/RoomList";
|
||||
import { logger, Logger } from "./logger";
|
||||
import { SERVICE_TYPES } from "./service-types";
|
||||
import {
|
||||
@@ -209,7 +209,7 @@ import { IgnoredInvites } from "./models/invites-ignorer";
|
||||
import { UIARequest, UIAResponse } from "./@types/uia";
|
||||
import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
|
||||
import { DeviceInfoMap } from "./crypto/DeviceList";
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
} from "./secret-storage";
|
||||
import { RegisterRequest, RegisterResponse } from "./@types/registration";
|
||||
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
|
||||
import { getRelationsThreadFilter } from "./thread-utils";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -951,6 +952,7 @@ type CryptoEvents =
|
||||
| CryptoEvent.KeyBackupStatus
|
||||
| CryptoEvent.KeyBackupFailed
|
||||
| CryptoEvent.KeyBackupSessionsRemaining
|
||||
| CryptoEvent.KeyBackupDecryptionKeyCached
|
||||
| CryptoEvent.RoomKeyRequest
|
||||
| CryptoEvent.RoomKeyRequestCancellation
|
||||
| CryptoEvent.VerificationRequest
|
||||
@@ -960,7 +962,8 @@ type CryptoEvents =
|
||||
| CryptoEvent.KeysChanged
|
||||
| CryptoEvent.Warning
|
||||
| CryptoEvent.DevicesUpdated
|
||||
| CryptoEvent.WillUpdateDevices;
|
||||
| CryptoEvent.WillUpdateDevices
|
||||
| CryptoEvent.LegacyCryptoStoreMigrationProgress;
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
@@ -1271,7 +1274,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected cryptoStore?: CryptoStore;
|
||||
protected verificationMethods?: VerificationMethod[];
|
||||
protected fallbackICEServerAllowed = false;
|
||||
protected roomList: RoomList;
|
||||
protected syncApi?: SlidingSyncSdk | SyncApi;
|
||||
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
|
||||
public pushRules?: IPushRules;
|
||||
@@ -1427,10 +1429,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
this.livekitServiceURL = opts.livekitServiceURL;
|
||||
|
||||
// List of which rooms have encryption enabled: separate from crypto because
|
||||
// we still want to know which rooms are encrypted even if crypto is disabled:
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
this.roomList = new RoomList(this.cryptoStore);
|
||||
this.roomNameGenerator = opts.roomNameGenerator;
|
||||
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
|
||||
@@ -2232,10 +2230,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.logger.debug("Crypto: Starting up crypto store...");
|
||||
await this.cryptoStore.startup();
|
||||
|
||||
// initialise the list of encrypted rooms (whether or not crypto is enabled)
|
||||
this.logger.debug("Crypto: initialising roomlist...");
|
||||
await this.roomList.init();
|
||||
|
||||
const userId = this.getUserId();
|
||||
if (userId === null) {
|
||||
throw new Error(
|
||||
@@ -2250,15 +2244,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
);
|
||||
}
|
||||
|
||||
const crypto = new Crypto(
|
||||
this,
|
||||
userId,
|
||||
this.deviceId,
|
||||
this.store,
|
||||
this.cryptoStore,
|
||||
this.roomList,
|
||||
this.verificationMethods!,
|
||||
);
|
||||
const crypto = new Crypto(this, userId, this.deviceId, this.store, this.cryptoStore, this.verificationMethods!);
|
||||
|
||||
this.reEmitter.reEmit(crypto, [
|
||||
CryptoEvent.KeyBackupFailed,
|
||||
@@ -2331,17 +2317,25 @@ 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.
|
||||
this.logger.debug("Downloading Rust crypto library");
|
||||
const RustCrypto = await import("./rust-crypto");
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(
|
||||
this.logger,
|
||||
this.http,
|
||||
userId,
|
||||
deviceId,
|
||||
this.secretStorage,
|
||||
this.cryptoCallbacks,
|
||||
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
|
||||
this.pickleKey,
|
||||
);
|
||||
|
||||
const rustCrypto = await RustCrypto.initRustCrypto({
|
||||
logger: this.logger,
|
||||
http: this.http,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
secretStorage: this.secretStorage,
|
||||
cryptoCallbacks: this.cryptoCallbacks,
|
||||
storePrefix: useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
|
||||
storePassphrase: this.pickleKey,
|
||||
legacyCryptoStore: this.cryptoStore,
|
||||
legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY",
|
||||
legacyMigrationProgressListener: (progress, total) => {
|
||||
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
|
||||
},
|
||||
});
|
||||
|
||||
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
|
||||
|
||||
this.cryptoBackend = rustCrypto;
|
||||
@@ -2359,6 +2353,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
CryptoEvent.KeyBackupStatus,
|
||||
CryptoEvent.KeyBackupSessionsRemaining,
|
||||
CryptoEvent.KeyBackupFailed,
|
||||
CryptoEvent.KeyBackupDecryptionKeyCached,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2393,6 +2388,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @returns base64-encoded ed25519 key. Null if crypto is
|
||||
* disabled.
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.getOwnDeviceKeys}
|
||||
*/
|
||||
public getDeviceEd25519Key(): string | null {
|
||||
return this.crypto?.getDeviceEd25519Key() ?? null;
|
||||
@@ -2403,6 +2400,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @returns base64-encoded curve25519 key. Null if crypto is
|
||||
* disabled.
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi.getOwnDeviceKeys}
|
||||
*/
|
||||
public getDeviceCurve25519Key(): string | null {
|
||||
return this.crypto?.getDeviceCurve25519Key() ?? null;
|
||||
@@ -3277,7 +3276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// we don't have an m.room.encrypted event, but that might be because
|
||||
// the server is hiding it from us. Check the store to see if it was
|
||||
// previously encrypted.
|
||||
return this.roomList.isRoomEncrypted(roomId);
|
||||
return this.crypto?.isRoomEncrypted(roomId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3640,6 +3639,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up and schedules them to
|
||||
* upload in the background as soon as possible.
|
||||
*
|
||||
* (This is done automatically as part of {@link CryptoApi.resetKeyBackup},
|
||||
* so there is probably no need to call this manually.)
|
||||
*/
|
||||
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
@@ -3652,6 +3654,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up without scheduling
|
||||
* them to upload in the background.
|
||||
*
|
||||
* (This is done automatically as part of {@link CryptoApi.resetKeyBackup},
|
||||
* so there is probably no need to call this manually.)
|
||||
*
|
||||
* @returns Promise which resolves to the number of sessions requiring a backup.
|
||||
*/
|
||||
public flagAllGroupSessionsForBackup(): Promise<number> {
|
||||
@@ -3899,7 +3905,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
let totalKeyCount = 0;
|
||||
let keys: IMegolmSessionData[] = [];
|
||||
let totalFailures = 0;
|
||||
let totalImported = 0;
|
||||
|
||||
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
|
||||
|
||||
@@ -3935,25 +3942,61 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
{ prefix: ClientPrefix.V3 },
|
||||
);
|
||||
|
||||
if ((res as IRoomsKeysResponse).rooms) {
|
||||
const rooms = (res as IRoomsKeysResponse).rooms;
|
||||
for (const [roomId, roomData] of Object.entries(rooms)) {
|
||||
if (!roomData.sessions) continue;
|
||||
// We have finished fetching the backup, go to next step
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
stage: "load_keys",
|
||||
});
|
||||
}
|
||||
|
||||
totalKeyCount += Object.keys(roomData.sessions).length;
|
||||
const roomKeys = await backupDecryptor.decryptSessions(roomData.sessions);
|
||||
for (const k of roomKeys) {
|
||||
k.room_id = roomId;
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
if ((res as IRoomsKeysResponse).rooms) {
|
||||
// We have a full backup here, it can get quite big, so we need to decrypt and import it in chunks.
|
||||
|
||||
// Get the total count as a first pass
|
||||
totalKeyCount = this.getTotalKeyCount(res as IRoomsKeysResponse);
|
||||
// Now decrypt and import the keys in chunks
|
||||
await this.handleDecryptionOfAFullBackup(
|
||||
res as IRoomsKeysResponse,
|
||||
backupDecryptor,
|
||||
200,
|
||||
async (chunk) => {
|
||||
// We have a chunk of decrypted keys: import them
|
||||
try {
|
||||
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, {
|
||||
untrusted,
|
||||
});
|
||||
totalImported += chunk.length;
|
||||
} catch (e) {
|
||||
totalFailures += chunk.length;
|
||||
// We failed to import some keys, but we should still try to import the rest?
|
||||
// Log the error and continue
|
||||
logger.error("Error importing keys from backup", e);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
total: totalKeyCount,
|
||||
successes: totalImported,
|
||||
stage: "load_keys",
|
||||
failures: totalFailures,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if ((res as IRoomKeysResponse).sessions) {
|
||||
// For now we don't chunk for a single room backup, but we could in the future.
|
||||
// Currently it is not used by the application.
|
||||
const sessions = (res as IRoomKeysResponse).sessions;
|
||||
totalKeyCount = Object.keys(sessions).length;
|
||||
keys = await backupDecryptor.decryptSessions(sessions);
|
||||
const keys = await backupDecryptor.decryptSessions(sessions);
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId!;
|
||||
}
|
||||
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
});
|
||||
totalImported = keys.length;
|
||||
} else {
|
||||
totalKeyCount = 1;
|
||||
try {
|
||||
@@ -3962,7 +4005,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
key.room_id = targetRoomId!;
|
||||
key.session_id = targetSessionId!;
|
||||
keys.push(key);
|
||||
|
||||
await this.cryptoBackend.importBackedUpRoomKeys([key], {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
});
|
||||
totalImported = 1;
|
||||
} catch (e) {
|
||||
this.logger.debug("Failed to decrypt megolm session from backup", e);
|
||||
}
|
||||
@@ -3971,16 +4019,88 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
backupDecryptor.free();
|
||||
}
|
||||
|
||||
await this.cryptoBackend.importRoomKeys(keys, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
source: "backup",
|
||||
});
|
||||
|
||||
/// in case entering the passphrase would add a new signature?
|
||||
await this.cryptoBackend.checkKeyBackupAndEnable();
|
||||
|
||||
return { total: totalKeyCount, imported: keys.length };
|
||||
return { total: totalKeyCount, imported: totalImported };
|
||||
}
|
||||
|
||||
/**
|
||||
* This method calculates the total number of keys present in the response of a `/room_keys/keys` call.
|
||||
*
|
||||
* @param res - The response from the server containing the keys to be counted.
|
||||
*
|
||||
* @returns The total number of keys in the backup.
|
||||
*/
|
||||
private getTotalKeyCount(res: IRoomsKeysResponse): number {
|
||||
const rooms = res.rooms;
|
||||
let totalKeyCount = 0;
|
||||
for (const roomData of Object.values(rooms)) {
|
||||
if (!roomData.sessions) continue;
|
||||
totalKeyCount += Object.keys(roomData.sessions).length;
|
||||
}
|
||||
return totalKeyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method handles the decryption of a full backup, i.e a call to `/room_keys/keys`.
|
||||
* It will decrypt the keys in chunks and call the `block` callback for each chunk.
|
||||
*
|
||||
* @param res - The response from the server containing the keys to be decrypted.
|
||||
* @param backupDecryptor - An instance of the BackupDecryptor class used to decrypt the keys.
|
||||
* @param chunkSize - The size of the chunks to be processed at a time.
|
||||
* @param block - A callback function that is called for each chunk of keys.
|
||||
*
|
||||
* @returns A promise that resolves when the decryption is complete.
|
||||
*/
|
||||
private async handleDecryptionOfAFullBackup(
|
||||
res: IRoomsKeysResponse,
|
||||
backupDecryptor: BackupDecryptor,
|
||||
chunkSize: number,
|
||||
block: (chunk: IMegolmSessionData[]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const rooms = (res as IRoomsKeysResponse).rooms;
|
||||
|
||||
let groupChunkCount = 0;
|
||||
let chunkGroupByRoom: Map<string, IKeyBackupRoomSessions> = new Map();
|
||||
|
||||
const handleChunkCallback = async (roomChunks: Map<string, IKeyBackupRoomSessions>): Promise<void> => {
|
||||
const currentChunk: IMegolmSessionData[] = [];
|
||||
for (const roomId of roomChunks.keys()) {
|
||||
const decryptedSessions = await backupDecryptor.decryptSessions(roomChunks.get(roomId)!);
|
||||
for (const sessionId in decryptedSessions) {
|
||||
const k = decryptedSessions[sessionId];
|
||||
k.room_id = roomId;
|
||||
currentChunk.push(k);
|
||||
}
|
||||
}
|
||||
await block(currentChunk);
|
||||
};
|
||||
|
||||
for (const [roomId, roomData] of Object.entries(rooms)) {
|
||||
if (!roomData.sessions) continue;
|
||||
|
||||
chunkGroupByRoom.set(roomId, {});
|
||||
|
||||
for (const [sessionId, session] of Object.entries(roomData.sessions)) {
|
||||
const sessionsForRoom = chunkGroupByRoom.get(roomId)!;
|
||||
sessionsForRoom[sessionId] = session;
|
||||
groupChunkCount += 1;
|
||||
if (groupChunkCount >= chunkSize) {
|
||||
// We have enough chunks to decrypt
|
||||
await handleChunkCallback(chunkGroupByRoom);
|
||||
chunkGroupByRoom = new Map();
|
||||
// There might be remaining keys for that room, so add back an entry for the current room.
|
||||
chunkGroupByRoom.set(roomId, {});
|
||||
groupChunkCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining chunk if needed
|
||||
if (groupChunkCount > 0) {
|
||||
await handleChunkCallback(chunkGroupByRoom);
|
||||
}
|
||||
}
|
||||
|
||||
public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise<void>;
|
||||
@@ -4007,7 +4127,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
|
||||
const roomEncryption = this.roomList.getRoomEncryption(roomId);
|
||||
const roomEncryption = this.crypto?.getRoomEncryption(roomId);
|
||||
if (!roomEncryption) {
|
||||
// unknown room, or unencrypted room
|
||||
this.logger.error("Unknown room. Not sharing decryption keys");
|
||||
@@ -5168,7 +5288,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const room = this.getRoom(event.getRoomId());
|
||||
if (room && this.credentials.userId) {
|
||||
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
|
||||
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType, unthreaded);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -5973,14 +6093,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const resOlder: IRelationsResponse = await this.fetchRelations(
|
||||
timelineSet.room.roomId,
|
||||
thread.id,
|
||||
THREAD_RELATION_TYPE.name,
|
||||
null,
|
||||
null,
|
||||
{ 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,
|
||||
null,
|
||||
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
|
||||
);
|
||||
@@ -5988,10 +6108,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// Order events from most recent to oldest (reverse-chronological).
|
||||
// We start with the last event, since that's the point at which we have known state.
|
||||
// events_after is already backwards; events_before is forwards.
|
||||
...resNewer.chunk.reverse().map(mapper),
|
||||
...resNewer.chunk.reverse().filter(getRelationsThreadFilter(thread.id)).map(mapper),
|
||||
event,
|
||||
...resOlder.chunk.map(mapper),
|
||||
...resOlder.chunk.filter(getRelationsThreadFilter(thread.id)).map(mapper),
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
await timelineSet.thread?.processEvent(event);
|
||||
}
|
||||
@@ -6366,6 +6487,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
|
||||
roomState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
|
||||
const token = res.end;
|
||||
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
|
||||
|
||||
@@ -6393,7 +6515,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
|
||||
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
|
||||
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, null, null, {
|
||||
dir,
|
||||
limit: opts.limit,
|
||||
from: token ?? undefined,
|
||||
@@ -6401,7 +6523,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
})
|
||||
.then(async (res) => {
|
||||
const mapper = this.getEventMapper();
|
||||
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
|
||||
const matrixEvents = res.chunk
|
||||
.filter(noUnsafeEventProps)
|
||||
.filter(getRelationsThreadFilter(thread.id))
|
||||
.map(mapper);
|
||||
|
||||
// Process latest events first
|
||||
for (const event of matrixEvents.slice().reverse()) {
|
||||
@@ -7449,16 +7574,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.serverVersionsPromise;
|
||||
}
|
||||
|
||||
// We send an authenticated request as of MSC4026
|
||||
this.serverVersionsPromise = this.http
|
||||
.request<IServerVersions>(
|
||||
Method.Get,
|
||||
"/_matrix/client/versions",
|
||||
undefined, // queryParams
|
||||
undefined, // data
|
||||
{
|
||||
prefix: "",
|
||||
},
|
||||
)
|
||||
.authedRequest<IServerVersions>(Method.Get, "/_matrix/client/versions", undefined, undefined, {
|
||||
prefix: "",
|
||||
})
|
||||
.catch((e) => {
|
||||
// Need to unset this if it fails, otherwise we'll never retry
|
||||
this.serverVersionsPromise = undefined;
|
||||
@@ -7596,7 +7716,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public async relations(
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
relationType?: RelationType | string | null,
|
||||
relationType: RelationType | string | null,
|
||||
eventType?: EventType | string | null,
|
||||
opts: IRelationsRequestOpts = { dir: Direction.Backward },
|
||||
): Promise<{
|
||||
@@ -7731,6 +7851,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public setAccessToken(token: string): void {
|
||||
this.http.opts.accessToken = token;
|
||||
// The /versions response can vary for different users so clear the cache
|
||||
this.serverVersionsPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8119,7 +8241,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public fetchRelations(
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
relationType?: RelationType | string | null,
|
||||
relationType: RelationType | string | null,
|
||||
eventType?: EventType | string | null,
|
||||
opts: IRelationsRequestOpts = { dir: Direction.Backward },
|
||||
): Promise<IRelationsResponse> {
|
||||
@@ -9832,6 +9954,19 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
|
||||
const room = cli.getRoom(event.getRoomId());
|
||||
if (!room || !ourUserId || !eventId) return;
|
||||
|
||||
// Due to threads, we can get relation events (eg. edits & reactions) that never get
|
||||
// added to a timeline and so cannot be found in their own room (their edit / reaction
|
||||
// still applies to the event it needs to, so it doesn't matter too much). However, if
|
||||
// we try to process notification about this event, we'll get very confused because we
|
||||
// won't be able to find the event in the room, so will assume it must be unread, even
|
||||
// if it's actually read. We therefore skip anything that isn't in the room. This isn't
|
||||
// *great*, so if we can fix the homeless events (eg. with MSC4023) then we should probably
|
||||
// remove this workaround.
|
||||
if (!room.findEventById(eventId)) {
|
||||
logger.info(`Decrypted event ${event.getId()} is not in room ${room.roomId}: ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
|
||||
|
||||
let hasReadEvent;
|
||||
@@ -9916,7 +10051,7 @@ export function threadIdForReceipt(event: MatrixEvent): string {
|
||||
* @returns true if this event is considered to be in the main timeline as far
|
||||
* as receipts are concerned.
|
||||
*/
|
||||
function inMainTimelineForReceipt(event: MatrixEvent): boolean {
|
||||
export function inMainTimelineForReceipt(event: MatrixEvent): boolean {
|
||||
if (!event.threadRootId) {
|
||||
// Not in a thread: then it is in the main timeline
|
||||
return true;
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { IClearEvent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
import { CryptoApi, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
@@ -108,6 +108,15 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @param privKey - The private decryption key.
|
||||
*/
|
||||
getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor>;
|
||||
|
||||
/**
|
||||
* Import a list of room keys restored from backup
|
||||
*
|
||||
* @param keys - a list of session export objects
|
||||
* @param opts - options object
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
|
||||
+45
-9
@@ -18,7 +18,7 @@ import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
import { UIAuthCallback } from "./interactive-auth";
|
||||
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
|
||||
import { PassphraseInfo, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
|
||||
import { VerificationRequest } from "./crypto-api/verification";
|
||||
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
|
||||
import { ISignatures } from "./@types/signed";
|
||||
@@ -46,6 +46,13 @@ export interface CryptoApi {
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Get the public part of the device keys for the current device.
|
||||
*
|
||||
* @returns The public device keys.
|
||||
*/
|
||||
getOwnDeviceKeys(): Promise<OwnDeviceKeys>;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
* send, in order to speed up sending of the message.
|
||||
@@ -162,7 +169,7 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Mark the given device as locally verified.
|
||||
*
|
||||
* Marking a devices as locally verified has much the same effect as completing the verification dance, or receiving
|
||||
* Marking a device as locally verified has much the same effect as completing the verification dance, or receiving
|
||||
* a cross-signing signature for it.
|
||||
*
|
||||
* @param userId - owner of the device
|
||||
@@ -175,6 +182,21 @@ export interface CryptoApi {
|
||||
*/
|
||||
setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cross-sign one of our own devices.
|
||||
*
|
||||
* This will create a signature for the device using our self-signing key, and publish that signature.
|
||||
* Cross-signing a device indicates, to our other devices and to other users, that we have verified that it really
|
||||
* belongs to us.
|
||||
*
|
||||
* Requires that cross-signing has been set up on this device (normally by calling {@link bootstrapCrossSigning}.
|
||||
*
|
||||
* *Note*: Do not call this unless you have verified, somehow, that the device is genuine!
|
||||
*
|
||||
* @param deviceId - ID of the device to be signed.
|
||||
*/
|
||||
crossSignDevice(deviceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Checks whether cross signing:
|
||||
* - is enabled on this account and trusted by this device
|
||||
@@ -564,9 +586,9 @@ export class DeviceVerificationStatus {
|
||||
*/
|
||||
export interface ImportRoomKeyProgressData {
|
||||
stage: string; // TODO: Enum
|
||||
successes: number;
|
||||
failures: number;
|
||||
total: number;
|
||||
successes?: number;
|
||||
failures?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -575,9 +597,10 @@ export interface ImportRoomKeyProgressData {
|
||||
export interface ImportRoomKeysOpts {
|
||||
/** Reports ongoing progress of the import process. Can be used for feedback. */
|
||||
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
|
||||
// TODO, the rust SDK will always such imported keys as untrusted
|
||||
/** @deprecated the rust SDK will always such imported keys as untrusted */
|
||||
untrusted?: boolean;
|
||||
source?: String; // TODO: Enum (backup, file, ??)
|
||||
/** @deprecated not useful externally */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -687,10 +710,15 @@ export interface CrossSigningKeyInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
|
||||
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} or {@link CreateSecretStorageOpts#createSecretStorageKey}.
|
||||
*/
|
||||
export interface GeneratedSecretStorageKey {
|
||||
keyInfo?: AddSecretStorageKeyOpts;
|
||||
keyInfo?: {
|
||||
/** If the key was derived from a passphrase, information (algorithm, salt, etc) on that derivation. */
|
||||
passphrase?: PassphraseInfo;
|
||||
/** Optional human-readable name for the key, to be stored in account_data. */
|
||||
name?: string;
|
||||
};
|
||||
/** The raw generated private key. */
|
||||
privateKey: Uint8Array;
|
||||
/** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */
|
||||
@@ -749,5 +777,13 @@ export enum EventShieldReason {
|
||||
MISMATCHED_SENDER_KEY,
|
||||
}
|
||||
|
||||
/** The result of a call to {@link CryptoApi.getOwnDeviceKeys} */
|
||||
export interface OwnDeviceKeys {
|
||||
/** Public part of the Ed25519 fingerprint key for the current device, base64 encoded. */
|
||||
ed25519: string;
|
||||
/** Public part of the Curve25519 identity key for the current device, base64 encoded. */
|
||||
curve25519: string;
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
export * from "./crypto-api/keybackup";
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface IRoomEncryption {
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
/**
|
||||
* Information about the encryption settings of rooms. Loads this information
|
||||
* from the supplied crypto store when `init()` is called, and saves it to the
|
||||
* crypto store whenever it is updated via `setRoomEncryption()`. Can supply
|
||||
* full information about a room's encryption via `getRoomEncryption()`, or just
|
||||
* answer whether or not a room has encryption via `isRoomEncrypted`.
|
||||
*/
|
||||
export class RoomList {
|
||||
// Object of roomId -> room e2e info object (body of the m.room.encryption event)
|
||||
private roomEncryption: Record<string, IRoomEncryption> = {};
|
||||
@@ -43,7 +50,7 @@ export class RoomList {
|
||||
});
|
||||
}
|
||||
|
||||
public getRoomEncryption(roomId: string): IRoomEncryption {
|
||||
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
||||
return this.roomEncryption[roomId] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ interface ISecretRequestInternal {
|
||||
export class SecretSharing {
|
||||
private requests = new Map<string, ISecretRequestInternal>();
|
||||
|
||||
public constructor(private readonly baseApis: MatrixClient, private readonly cryptoCallbacks: ICryptoCallbacks) {}
|
||||
public constructor(
|
||||
private readonly baseApis: MatrixClient,
|
||||
private readonly cryptoCallbacks: ICryptoCallbacks,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Request a secret from another device
|
||||
|
||||
@@ -73,11 +73,7 @@ export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> im
|
||||
/**
|
||||
* Add a key for encrypting secrets.
|
||||
*/
|
||||
public addKey(
|
||||
algorithm: string,
|
||||
opts: AddSecretStorageKeyOpts = {},
|
||||
keyId?: string,
|
||||
): Promise<SecretStorageKeyObject> {
|
||||
public addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject> {
|
||||
return this.storageImpl.addKey(algorithm, opts, keyId);
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,11 @@ export abstract class DecryptionAlgorithm {
|
||||
export class DecryptionError extends Error {
|
||||
public readonly detailedString: string;
|
||||
|
||||
public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) {
|
||||
public constructor(
|
||||
public readonly code: string,
|
||||
msg: string,
|
||||
details?: Record<string, string | Error>,
|
||||
) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
this.name = "DecryptionError";
|
||||
@@ -242,7 +246,11 @@ export class UnknownDeviceError extends Error {
|
||||
* @param msg - message describing the problem
|
||||
* @param devices - set of unknown devices per user we're warning about
|
||||
*/
|
||||
public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) {
|
||||
public constructor(
|
||||
msg: string,
|
||||
public readonly devices: DeviceInfoMap,
|
||||
public event?: MatrixEvent,
|
||||
) {
|
||||
super(msg);
|
||||
this.name = "UnknownDeviceError";
|
||||
this.devices = devices;
|
||||
|
||||
@@ -164,7 +164,10 @@ class OutboundSessionInfo {
|
||||
* @param sharedHistory - whether the session can be freely shared with
|
||||
* other group members, according to the room history visibility settings
|
||||
*/
|
||||
public constructor(public readonly sessionId: string, public readonly sharedHistory = false) {
|
||||
public constructor(
|
||||
public readonly sessionId: string,
|
||||
public readonly sharedHistory = false,
|
||||
) {
|
||||
this.creationTime = new Date().getTime();
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,10 @@ export class BackupManager {
|
||||
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
|
||||
private clientRunning = true;
|
||||
|
||||
public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
|
||||
public constructor(
|
||||
private readonly baseApis: MatrixClient,
|
||||
public readonly getKey: GetKey,
|
||||
) {
|
||||
this.checkedForBackup = false;
|
||||
this.sendingBackups = false;
|
||||
}
|
||||
@@ -773,7 +776,10 @@ const UNSTABLE_MSC3270_NAME = new UnstableValue(
|
||||
export class Aes256 implements BackupAlgorithm {
|
||||
public static algorithmName = UNSTABLE_MSC3270_NAME.name;
|
||||
|
||||
public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {}
|
||||
public constructor(
|
||||
public readonly authData: IAes256AuthData,
|
||||
private readonly key: Uint8Array,
|
||||
) {}
|
||||
|
||||
public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
|
||||
if (!authData) {
|
||||
|
||||
+128
-34
@@ -64,7 +64,7 @@ import {
|
||||
IUploadKeySignaturesResponse,
|
||||
MatrixClient,
|
||||
} from "../client";
|
||||
import type { IRoomEncryption, RoomList } from "./RoomList";
|
||||
import { IRoomEncryption, RoomList } from "./RoomList";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { ISyncStateData } from "../sync";
|
||||
import { CryptoStore } from "./store/base";
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
OwnDeviceKeys,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
@@ -231,6 +232,18 @@ export enum CryptoEvent {
|
||||
KeyBackupStatus = "crypto.keyBackupStatus",
|
||||
KeyBackupFailed = "crypto.keyBackupFailed",
|
||||
KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining",
|
||||
|
||||
/**
|
||||
* Fires when a new valid backup decryption key is in cache.
|
||||
* This will happen when a secret is received from another session, from secret storage,
|
||||
* or when a new backup is created from this session.
|
||||
*
|
||||
* The payload is the version of the backup for which we have the key for.
|
||||
*
|
||||
* This event is only fired by the rust crypto backend.
|
||||
*/
|
||||
KeyBackupDecryptionKeyCached = "crypto.keyBackupDecryptionKeyCached",
|
||||
|
||||
KeySignatureUploadFailure = "crypto.keySignatureUploadFailure",
|
||||
/** @deprecated Use `VerificationRequestReceived`. */
|
||||
VerificationRequest = "crypto.verification.request",
|
||||
@@ -246,6 +259,15 @@ export enum CryptoEvent {
|
||||
WillUpdateDevices = "crypto.willUpdateDevices",
|
||||
DevicesUpdated = "crypto.devicesUpdated",
|
||||
KeysChanged = "crossSigning.keysChanged",
|
||||
|
||||
/**
|
||||
* Fires when data is being migrated from legacy crypto to rust crypto.
|
||||
*
|
||||
* The payload is a pair `(progress, total)`, where `progress` is the number of steps completed so far, and
|
||||
* `total` is the total number of steps. When migration is complete, a final instance of the event is emitted, with
|
||||
* `progress === total === -1`.
|
||||
*/
|
||||
LegacyCryptoStoreMigrationProgress = "crypto.legacyCryptoStoreMigrationProgress",
|
||||
}
|
||||
|
||||
export type CryptoEventHandlerMap = {
|
||||
@@ -296,6 +318,13 @@ export type CryptoEventHandlerMap = {
|
||||
[CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void;
|
||||
[CryptoEvent.KeyBackupFailed]: (errcode: string) => void;
|
||||
[CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void;
|
||||
|
||||
/**
|
||||
* Fires when the backup decryption key is received and cached.
|
||||
*
|
||||
* @param version - The version of the backup for which we have the key for.
|
||||
*/
|
||||
[CryptoEvent.KeyBackupDecryptionKeyCached]: (version: string) => void;
|
||||
[CryptoEvent.KeySignatureUploadFailure]: (
|
||||
failures: IUploadKeySignaturesResponse["failures"],
|
||||
source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification",
|
||||
@@ -348,6 +377,8 @@ export type CryptoEventHandlerMap = {
|
||||
*/
|
||||
[CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
|
||||
[CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void;
|
||||
|
||||
[CryptoEvent.LegacyCryptoStoreMigrationProgress]: (progress: number, total: number) => void;
|
||||
};
|
||||
|
||||
export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap> implements CryptoBackend {
|
||||
@@ -365,6 +396,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public readonly dehydrationManager: DehydrationManager;
|
||||
public readonly secretStorage: LegacySecretStorage;
|
||||
|
||||
private readonly roomList: RoomList;
|
||||
private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>;
|
||||
private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>;
|
||||
public readonly supportedAlgorithms: string[];
|
||||
@@ -453,10 +485,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
private readonly deviceId: string,
|
||||
private readonly clientStore: IStore,
|
||||
public readonly cryptoStore: CryptoStore,
|
||||
private readonly roomList: RoomList,
|
||||
verificationMethods: Array<VerificationMethod | (typeof VerificationBase & { NAME: string })>,
|
||||
) {
|
||||
super();
|
||||
|
||||
logger.debug("Crypto: initialising roomlist...");
|
||||
this.roomList = new RoomList(cryptoStore);
|
||||
|
||||
this.reEmitter = new TypedReEmitter(this);
|
||||
|
||||
if (verificationMethods) {
|
||||
@@ -606,6 +641,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// (this is important for key backups & things)
|
||||
this.deviceList.startTrackingDeviceList(this.userId);
|
||||
|
||||
logger.debug("Crypto: initialising roomlist...");
|
||||
await this.roomList.init();
|
||||
|
||||
logger.log("Crypto: checking for key backup...");
|
||||
this.backupManager.checkAndStart();
|
||||
}
|
||||
@@ -681,25 +719,30 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public async createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
try {
|
||||
const keyInfo: Partial<IRecoveryKey["keyInfo"]> = {};
|
||||
if (password) {
|
||||
const derivation = await keyFromPassphrase(password);
|
||||
keyInfo.passphrase = {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: derivation.iterations,
|
||||
salt: derivation.salt,
|
||||
|
||||
decryption.init_with_private_key(derivation.key);
|
||||
const privateKey = decryption.get_private_key();
|
||||
return {
|
||||
keyInfo: {
|
||||
passphrase: {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: derivation.iterations,
|
||||
salt: derivation.salt,
|
||||
},
|
||||
},
|
||||
privateKey: privateKey,
|
||||
encodedPrivateKey: encodeRecoveryKey(privateKey),
|
||||
};
|
||||
keyInfo.pubkey = decryption.init_with_private_key(derivation.key);
|
||||
} else {
|
||||
keyInfo.pubkey = decryption.generate_key();
|
||||
decryption.generate_key();
|
||||
const privateKey = decryption.get_private_key();
|
||||
return {
|
||||
privateKey: privateKey,
|
||||
encodedPrivateKey: encodeRecoveryKey(privateKey),
|
||||
};
|
||||
}
|
||||
const privateKey = decryption.get_private_key();
|
||||
const encodedPrivateKey = encodeRecoveryKey(privateKey);
|
||||
return {
|
||||
keyInfo: keyInfo as IRecoveryKey["keyInfo"],
|
||||
encodedPrivateKey,
|
||||
privateKey,
|
||||
};
|
||||
} finally {
|
||||
decryption?.free();
|
||||
}
|
||||
@@ -941,7 +984,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
// TODO this does not resolve with what it says it does
|
||||
public async bootstrapSecretStorage({
|
||||
createSecretStorageKey = async (): Promise<IRecoveryKey> => ({} as IRecoveryKey),
|
||||
createSecretStorageKey = async (): Promise<IRecoveryKey> => ({}) as IRecoveryKey,
|
||||
keyBackupInfo,
|
||||
setupNewKeyBackup,
|
||||
setupNewSecretStorage,
|
||||
@@ -959,17 +1002,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
let newKeyId: string | null = null;
|
||||
|
||||
// create a new SSSS key and set it as default
|
||||
const createSSSS = async (opts: AddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
|
||||
if (privateKey) {
|
||||
opts.key = privateKey;
|
||||
}
|
||||
|
||||
const createSSSS = async (opts: AddSecretStorageKeyOpts): Promise<string> => {
|
||||
const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts);
|
||||
|
||||
if (privateKey) {
|
||||
// make the private key available to encrypt 4S secrets
|
||||
builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey);
|
||||
}
|
||||
// make the private key available to encrypt 4S secrets
|
||||
builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, opts.key);
|
||||
|
||||
await secretStorage.setDefaultKeyId(keyId);
|
||||
return keyId;
|
||||
@@ -1033,8 +1070,8 @@ 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 AddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
|
||||
newKeyId = await createSSSS(keyInfo, privateKey);
|
||||
const { keyInfo, privateKey } = await createSecretStorageKey();
|
||||
newKeyId = await createSSSS({ passphrase: keyInfo?.passphrase, key: privateKey, name: keyInfo?.name });
|
||||
} else if (!storageExists && keyBackupInfo) {
|
||||
// we have an existing backup, but no SSSS
|
||||
logger.log("Secret storage does not exist, using key backup key");
|
||||
@@ -1044,7 +1081,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 AddSecretStorageKeyOpts;
|
||||
const opts = { key: backupKey } as AddSecretStorageKeyOpts;
|
||||
|
||||
if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) {
|
||||
// FIXME: ???
|
||||
@@ -1056,7 +1093,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
};
|
||||
}
|
||||
|
||||
newKeyId = await createSSSS(opts, backupKey);
|
||||
newKeyId = await createSSSS(opts);
|
||||
|
||||
// store the backup key in secret storage
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]);
|
||||
@@ -1191,6 +1228,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await this.storeSessionBackupPrivateKey(privateKey);
|
||||
|
||||
await this.backupManager.checkAndStart();
|
||||
await this.backupManager.scheduleAllGroupSessionsForBackup();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1876,6 +1914,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return new LibOlmBackupDecryptor(algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public importBackedUpRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise<void> {
|
||||
opts.source = "backup";
|
||||
return this.importRoomKeys(keys, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a set of keys as our own, trusted, cross-signing keys.
|
||||
*
|
||||
@@ -1968,6 +2014,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* Get the Ed25519 key for this device
|
||||
*
|
||||
* @returns base64-encoded ed25519 key.
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
public getDeviceEd25519Key(): string | null {
|
||||
return this.olmDevice.deviceEd25519Key;
|
||||
@@ -1977,11 +2025,29 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* Get the Curve25519 key for this device
|
||||
*
|
||||
* @returns base64-encoded curve25519 key.
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}
|
||||
*/
|
||||
public getDeviceCurve25519Key(): string | null {
|
||||
return this.olmDevice.deviceCurve25519Key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
public async getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
|
||||
if (!this.olmDevice.deviceCurve25519Key) {
|
||||
throw new Error("Curve25519 key not yet created");
|
||||
}
|
||||
if (!this.olmDevice.deviceEd25519Key) {
|
||||
throw new Error("Ed25519 key not yet created");
|
||||
}
|
||||
return {
|
||||
ed25519: this.olmDevice.deviceEd25519Key,
|
||||
curve25519: this.olmDevice.deviceCurve25519Key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global override for whether the client should ever send encrypted
|
||||
* messages to unverified devices. This provides the default for rooms which
|
||||
@@ -2188,10 +2254,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
const res = await this.baseApis.uploadKeysRequest(requestBody);
|
||||
|
||||
if (fallbackJson) {
|
||||
this.fallbackCleanup = setTimeout(() => {
|
||||
delete this.fallbackCleanup;
|
||||
this.olmDevice.forgetOldFallbackKey();
|
||||
}, 60 * 60 * 1000);
|
||||
this.fallbackCleanup = setTimeout(
|
||||
() => {
|
||||
delete this.fallbackCleanup;
|
||||
this.olmDevice.forgetOldFallbackKey();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
await this.olmDevice.markKeysAsPublished();
|
||||
@@ -2306,6 +2375,15 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await this.setDeviceVerification(userId, deviceId, verified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blindly cross-sign one of our other devices.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#crossSignDevice}.
|
||||
*/
|
||||
public async crossSignDevice(deviceId: string): Promise<void> {
|
||||
await this.setDeviceVerified(this.userId, deviceId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the blocked/verified state of the given device
|
||||
*
|
||||
@@ -4186,6 +4264,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
obj.signatures = recursiveMapToObject(sigs);
|
||||
if (unsigned !== undefined) obj.unsigned = unsigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the room with the supplied ID is encrypted. False if the
|
||||
* room is not encrypted, or is unknown to us.
|
||||
*/
|
||||
public isRoomEncrypted(roomId: string): boolean {
|
||||
return this.roomList.isRoomEncrypted(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns information about the encryption on the room with the supplied
|
||||
* ID, or null if the room is not encrypted or unknown to us.
|
||||
*/
|
||||
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
||||
return this.roomList.getRoomEncryption(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
// Export for backward compatibility
|
||||
import { ImportRoomKeyProgressData } from "../crypto-api";
|
||||
|
||||
export type {
|
||||
Curve25519AuthData as ICurve25519AuthData,
|
||||
Aes256AuthData as IAes256AuthData,
|
||||
@@ -41,5 +43,5 @@ export interface IKeyBackupRestoreResult {
|
||||
|
||||
export interface IKeyBackupRestoreOpts {
|
||||
cacheCompleteCallback?: () => void;
|
||||
progressCallback?: (progress: { stage: string }) => void;
|
||||
progressCallback?: (progress: ImportRoomKeyProgressData) => void;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,41 @@ export interface SecretStorePrivateKeys {
|
||||
* Abstraction of things that can store data required for end-to-end encryption
|
||||
*/
|
||||
export interface CryptoStore {
|
||||
/**
|
||||
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
|
||||
*
|
||||
* Unlike the rest of the methods in this interface, can be called before {@link CryptoStore#startup}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
containsData(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Initialise this crypto store.
|
||||
*
|
||||
* Typically, this involves provisioning storage, and migrating any existing data to the current version of the
|
||||
* storage schema where appropriate.
|
||||
*
|
||||
* Must be called before any of the rest of the methods in this interface.
|
||||
*/
|
||||
startup(): Promise<CryptoStore>;
|
||||
|
||||
deleteAllData(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getMigrationState(): Promise<MigrationState>;
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
setMigrationState(migrationState: MigrationState): Promise<void>;
|
||||
|
||||
getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
|
||||
getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
|
||||
getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
|
||||
@@ -99,6 +132,23 @@ export interface CryptoStore {
|
||||
getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
|
||||
filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
|
||||
|
||||
/**
|
||||
* Get a batch of end-to-end sessions from the database.
|
||||
*
|
||||
* @returns A batch of Olm Sessions, or `null` if no sessions are left.
|
||||
* @internal
|
||||
*/
|
||||
getEndToEndSessionsBatch(): Promise<ISessionInfo[] | null>;
|
||||
|
||||
/**
|
||||
* Delete a batch of end-to-end sessions from the database.
|
||||
*
|
||||
* Any sessions in the list which are not found are silently ignored.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
deleteEndToEndSessionsBatch(sessions: { deviceKey?: string; sessionId?: string }[]): Promise<void>;
|
||||
|
||||
// Inbound Group Sessions
|
||||
getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
@@ -126,6 +176,30 @@ export interface CryptoStore {
|
||||
txn: unknown,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Count the number of Megolm sessions in the database.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
countEndToEndInboundGroupSessions(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get a batch of Megolm sessions from the database.
|
||||
*
|
||||
* @returns A batch of Megolm Sessions, or `null` if no sessions are left.
|
||||
* @internal
|
||||
*/
|
||||
getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null>;
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Any sessions in the list which are not found are silently ignored.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise<void>;
|
||||
|
||||
// Device Data
|
||||
getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
|
||||
storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
|
||||
@@ -149,12 +223,19 @@ export interface CryptoStore {
|
||||
|
||||
export type Mode = "readonly" | "readwrite";
|
||||
|
||||
/** Data on a Megolm session */
|
||||
export interface ISession {
|
||||
senderKey: string;
|
||||
sessionId: string;
|
||||
sessionData?: InboundGroupSessionData;
|
||||
}
|
||||
|
||||
/** Extended data on a Megolm session */
|
||||
export interface SessionExtended extends ISession {
|
||||
needsBackup: boolean;
|
||||
}
|
||||
|
||||
/** Data on an Olm session */
|
||||
export interface ISessionInfo {
|
||||
deviceKey?: string;
|
||||
sessionId?: string;
|
||||
@@ -224,3 +305,30 @@ export interface ParkedSharedHistory {
|
||||
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
|
||||
forwardingCurve25519KeyChain: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A record of which steps have been completed in the libolm to Rust Crypto migration.
|
||||
*
|
||||
* Used by {@link CryptoStore#getMigrationState} and {@link CryptoStore#setMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export enum MigrationState {
|
||||
/** No migration steps have yet been completed. */
|
||||
NOT_STARTED,
|
||||
|
||||
/** We have migrated the account data, cross-signing keys, etc. */
|
||||
INITIAL_DATA_MIGRATED,
|
||||
|
||||
/** INITIAL_DATA_MIGRATED, and in addition, we have migrated all the Olm sessions. */
|
||||
OLM_SESSIONS_MIGRATED,
|
||||
|
||||
/** OLM_SESSIONS_MIGRATED, and in addition, we have migrated all the Megolm sessions. */
|
||||
MEGOLM_SESSIONS_MIGRATED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The size of batches to be returned by {@link CryptoStore#getEndToEndSessionsBatch} and
|
||||
* {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}.
|
||||
*/
|
||||
export const SESSION_BATCH_SIZE = 50;
|
||||
|
||||
@@ -21,25 +21,34 @@ import {
|
||||
IDeviceData,
|
||||
IProblem,
|
||||
ISession,
|
||||
SessionExtended,
|
||||
ISessionInfo,
|
||||
IWithheld,
|
||||
MigrationState,
|
||||
Mode,
|
||||
OutgoingRoomKeyRequest,
|
||||
ParkedSharedHistory,
|
||||
SecretStorePrivateKeys,
|
||||
SESSION_BATCH_SIZE,
|
||||
} from "./base";
|
||||
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store";
|
||||
|
||||
const PROFILE_TRANSACTIONS = false;
|
||||
|
||||
/* Keys for the `account` object store */
|
||||
const ACCOUNT_OBJECT_KEY_MIGRATION_STATE = "migrationState";
|
||||
|
||||
/**
|
||||
* Implementation of a CryptoStore which is backed by an existing
|
||||
* IndexedDB connection. Generally you want IndexedDBCryptoStore
|
||||
* which connects to the database and defers to one of these.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class Backend implements CryptoStore {
|
||||
private nextTxnId = 0;
|
||||
@@ -56,15 +65,49 @@ export class Backend implements CryptoStore {
|
||||
};
|
||||
}
|
||||
|
||||
public async containsData(): Promise<boolean> {
|
||||
throw Error("Not implemented for Backend");
|
||||
}
|
||||
|
||||
public async startup(): Promise<CryptoStore> {
|
||||
// No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
|
||||
// by passing us a ready IDBDatabase instance
|
||||
return this;
|
||||
}
|
||||
|
||||
public async deleteAllData(): Promise<void> {
|
||||
throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*/
|
||||
public async getMigrationState(): Promise<MigrationState> {
|
||||
let migrationState = MigrationState.NOT_STARTED;
|
||||
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
|
||||
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
|
||||
getReq.onsuccess = (): void => {
|
||||
migrationState = getReq.result ?? MigrationState.NOT_STARTED;
|
||||
};
|
||||
});
|
||||
return migrationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*/
|
||||
public async setMigrationState(migrationState: MigrationState): Promise<void> {
|
||||
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
|
||||
objectStore.put(migrationState, ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an existing outgoing room key request, and if none is found,
|
||||
* add a new one
|
||||
@@ -588,6 +631,62 @@ export class Backend implements CryptoStore {
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
|
||||
*/
|
||||
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
|
||||
const result: ISessionInfo[] = [];
|
||||
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
|
||||
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
|
||||
const getReq = objectStore.openCursor();
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
const cursor = getReq.result;
|
||||
if (cursor) {
|
||||
result.push(cursor.value);
|
||||
if (result.length < SESSION_BATCH_SIZE) {
|
||||
cursor.continue();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
|
||||
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], async (txn) => {
|
||||
try {
|
||||
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
|
||||
for (const { deviceKey, sessionId } of sessions) {
|
||||
const req = objectStore.delete([deviceKey, sessionId]);
|
||||
await new Promise((resolve) => {
|
||||
req.onsuccess = resolve;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Inbound group sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
@@ -712,6 +811,97 @@ export class Backend implements CryptoStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of Megolm sessions in the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async countEndToEndInboundGroupSessions(): Promise<number> {
|
||||
let result = 0;
|
||||
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
|
||||
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
|
||||
const countReq = sessionStore.count();
|
||||
countReq.onsuccess = (): void => {
|
||||
result = countReq.result;
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
|
||||
*/
|
||||
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
|
||||
const result: SessionExtended[] = [];
|
||||
await this.doTxn(
|
||||
"readonly",
|
||||
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
|
||||
(txn) => {
|
||||
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
|
||||
const backupStore = txn.objectStore(IndexedDBCryptoStore.STORE_BACKUP);
|
||||
|
||||
const getReq = sessionStore.openCursor();
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
const cursor = getReq.result;
|
||||
if (cursor) {
|
||||
const backupGetReq = backupStore.get(cursor.key);
|
||||
backupGetReq.onsuccess = (): void => {
|
||||
result.push({
|
||||
senderKey: cursor.value.senderCurve25519Key,
|
||||
sessionId: cursor.value.sessionId,
|
||||
sessionData: cursor.value.session,
|
||||
needsBackup: backupGetReq.result !== undefined,
|
||||
});
|
||||
if (result.length < SESSION_BATCH_SIZE) {
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndInboundGroupSessionsBatch(
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], async (txn) => {
|
||||
try {
|
||||
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
|
||||
for (const { senderKey, sessionId } of sessions) {
|
||||
const req = objectStore.delete([senderKey, sessionId]);
|
||||
await new Promise((resolve) => {
|
||||
req.onsuccess = resolve;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
|
||||
const objectStore = txn.objectStore("device_data");
|
||||
const getReq = objectStore.get("-");
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
IDeviceData,
|
||||
IProblem,
|
||||
ISession,
|
||||
SessionExtended,
|
||||
ISessionInfo,
|
||||
IWithheld,
|
||||
MigrationState,
|
||||
Mode,
|
||||
OutgoingRoomKeyRequest,
|
||||
ParkedSharedHistory,
|
||||
@@ -38,7 +40,7 @@ import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
|
||||
/**
|
||||
/*
|
||||
* Internal module. indexeddb storage for e2e.
|
||||
*/
|
||||
|
||||
@@ -70,7 +72,21 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
* @param indexedDB - global indexedDB instance
|
||||
* @param dbName - name of db to connect to
|
||||
*/
|
||||
public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {}
|
||||
public constructor(
|
||||
private readonly indexedDB: IDBFactory,
|
||||
private readonly dbName: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
|
||||
*
|
||||
* Implementation of {@link CryptoStore.containsData}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async containsData(): Promise<boolean> {
|
||||
return IndexedDBCryptoStore.exists(this.indexedDB, this.dbName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the database exists and is up-to-date, or fall back to
|
||||
@@ -197,6 +213,28 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getMigrationState(): Promise<MigrationState> {
|
||||
return this.backend!.getMigrationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public setMigrationState(migrationState: MigrationState): Promise<void> {
|
||||
return this.backend!.setMigrationState(migrationState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an existing outgoing room key request, and if none is found,
|
||||
* add a new one
|
||||
@@ -468,6 +506,39 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
return this.backend!.filterOutNotifiedErrorDevices(devices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of Megolm sessions in the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public countEndToEndInboundGroupSessions(): Promise<number> {
|
||||
return this.backend!.countEndToEndInboundGroupSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
|
||||
return this.backend!.getEndToEndSessionsBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
|
||||
return this.backend!.deleteEndToEndSessionsBatch(sessions);
|
||||
}
|
||||
|
||||
// Inbound group sessions
|
||||
|
||||
/**
|
||||
@@ -544,6 +615,30 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
|
||||
return this.backend!.getEndToEndInboundGroupSessionsBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public deleteEndToEndInboundGroupSessionsBatch(
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
|
||||
}
|
||||
|
||||
// End-to-end device tracking
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,19 @@ limitations under the License.
|
||||
|
||||
import { logger } from "../../logger";
|
||||
import { MemoryCryptoStore } from "./memory-crypto-store";
|
||||
import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
IProblem,
|
||||
ISession,
|
||||
SessionExtended,
|
||||
ISessionInfo,
|
||||
IWithheld,
|
||||
MigrationState,
|
||||
Mode,
|
||||
SecretStorePrivateKeys,
|
||||
SESSION_BATCH_SIZE,
|
||||
} from "./base";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
@@ -32,6 +44,7 @@ import { safeSet } from "../../utils";
|
||||
*/
|
||||
|
||||
const E2E_PREFIX = "crypto.";
|
||||
const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration";
|
||||
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
|
||||
const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
|
||||
const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
|
||||
@@ -61,7 +74,7 @@ function keyEndToEndRoomsPrefix(roomId: string): string {
|
||||
return KEY_ROOMS_PREFIX + roomId;
|
||||
}
|
||||
|
||||
export class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
export class LocalStorageCryptoStore extends MemoryCryptoStore implements CryptoStore {
|
||||
public static exists(store: Storage): boolean {
|
||||
const length = store.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
@@ -76,12 +89,49 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
|
||||
*
|
||||
* Implementation of {@link CryptoStore.containsData}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async containsData(): Promise<boolean> {
|
||||
return LocalStorageCryptoStore.exists(this.store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getMigrationState(): Promise<MigrationState> {
|
||||
return getJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE) ?? MigrationState.NOT_STARTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async setMigrationState(migrationState: MigrationState): Promise<void> {
|
||||
setJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE, migrationState);
|
||||
}
|
||||
|
||||
// Olm Sessions
|
||||
|
||||
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
|
||||
let count = 0;
|
||||
for (let i = 0; i < this.store.length; ++i) {
|
||||
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count;
|
||||
const key = this.store.key(i);
|
||||
if (key?.startsWith(keyEndToEndSessions(""))) {
|
||||
const sessions = getJsonItem(this.store, key);
|
||||
count += Object.keys(sessions ?? {}).length;
|
||||
}
|
||||
}
|
||||
func(count);
|
||||
}
|
||||
@@ -192,6 +242,56 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
|
||||
const result: ISessionInfo[] = [];
|
||||
for (let i = 0; i < this.store.length; ++i) {
|
||||
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
|
||||
const deviceKey = this.store.key(i)!.split("/")[1];
|
||||
for (const session of Object.values(this._getEndToEndSessions(deviceKey))) {
|
||||
result.push(session);
|
||||
if (result.length >= SESSION_BATCH_SIZE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
// There are fewer sessions than the batch size; return the final batch of sessions.
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
|
||||
for (const { deviceKey, sessionId } of sessions) {
|
||||
const deviceSessions = this._getEndToEndSessions(deviceKey) || {};
|
||||
delete deviceSessions[sessionId];
|
||||
if (Object.keys(deviceSessions).length === 0) {
|
||||
// No more sessions for this device.
|
||||
this.store.removeItem(keyEndToEndSessions(deviceKey));
|
||||
} else {
|
||||
setJsonItem(this.store, keyEndToEndSessions(deviceKey), deviceSessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound Group Sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
@@ -255,6 +355,82 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of Megolm sessions in the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async countEndToEndInboundGroupSessions(): Promise<number> {
|
||||
let count = 0;
|
||||
for (let i = 0; i < this.store.length; ++i) {
|
||||
const key = this.store.key(i);
|
||||
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
|
||||
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
|
||||
const result: SessionExtended[] = [];
|
||||
for (let i = 0; i < this.store.length; ++i) {
|
||||
const key = this.store.key(i);
|
||||
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
|
||||
const key2 = key.slice(KEY_INBOUND_SESSION_PREFIX.length);
|
||||
|
||||
// we can't use split, as the components we are trying to split out
|
||||
// might themselves contain '/' characters. We rely on the
|
||||
// senderKey being a (32-byte) curve25519 key, base64-encoded
|
||||
// (hence 43 characters long).
|
||||
|
||||
result.push({
|
||||
senderKey: key2.slice(0, 43),
|
||||
sessionId: key2.slice(44),
|
||||
sessionData: getJsonItem(this.store, key)!,
|
||||
needsBackup: key2 in sessionsNeedingBackup,
|
||||
});
|
||||
|
||||
if (result.length >= SESSION_BATCH_SIZE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
// There are fewer sessions than the batch size; return the final batch of sessions.
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndInboundGroupSessionsBatch(
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
for (const { senderKey, sessionId } of sessions) {
|
||||
const k = keyEndToEndInboundGroupSession(senderKey, sessionId);
|
||||
this.store.removeItem(k);
|
||||
}
|
||||
}
|
||||
|
||||
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
|
||||
func(getJsonItem(this.store, KEY_DEVICE_DATA));
|
||||
}
|
||||
|
||||
@@ -15,18 +15,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logger";
|
||||
import { safeSet, deepCompare, promiseTry } from "../../utils";
|
||||
import { deepCompare, promiseTry, safeSet } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
IProblem,
|
||||
ISession,
|
||||
SessionExtended,
|
||||
ISessionInfo,
|
||||
IWithheld,
|
||||
MigrationState,
|
||||
Mode,
|
||||
OutgoingRoomKeyRequest,
|
||||
ParkedSharedHistory,
|
||||
SecretStorePrivateKeys,
|
||||
SESSION_BATCH_SIZE,
|
||||
} from "./base";
|
||||
import { IRoomKeyRequestBody } from "../index";
|
||||
import { ICrossSigningKey } from "../../client";
|
||||
@@ -39,6 +42,7 @@ import { InboundGroupSessionData } from "../OlmDevice";
|
||||
*/
|
||||
|
||||
export class MemoryCryptoStore implements CryptoStore {
|
||||
private migrationState: MigrationState = MigrationState.NOT_STARTED;
|
||||
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
|
||||
private account: string | null = null;
|
||||
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
|
||||
@@ -56,6 +60,18 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
|
||||
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
|
||||
|
||||
/**
|
||||
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
|
||||
*
|
||||
* Implementation of {@link CryptoStore.containsData}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async containsData(): Promise<boolean> {
|
||||
// If it contains anything, it should contain an account.
|
||||
return this.account !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the database exists and is up-to-date.
|
||||
*
|
||||
@@ -77,6 +93,28 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getMigrationState(): Promise<MigrationState> {
|
||||
return this.migrationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async setMigrationState(migrationState: MigrationState): Promise<void> {
|
||||
this.migrationState = migrationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an existing outgoing room key request, and if none is found,
|
||||
* add a new one
|
||||
@@ -298,7 +336,11 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
// Olm Sessions
|
||||
|
||||
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
|
||||
func(Object.keys(this.sessions).length);
|
||||
let count = 0;
|
||||
for (const deviceSessions of Object.values(this.sessions)) {
|
||||
count += Object.keys(deviceSessions).length;
|
||||
}
|
||||
func(count);
|
||||
}
|
||||
|
||||
public getEndToEndSession(
|
||||
@@ -386,6 +428,51 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
|
||||
const result: ISessionInfo[] = [];
|
||||
for (const deviceSessions of Object.values(this.sessions)) {
|
||||
for (const session of Object.values(deviceSessions)) {
|
||||
result.push(session);
|
||||
if (result.length >= SESSION_BATCH_SIZE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
// There are fewer sessions than the batch size; return the final batch of sessions.
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
|
||||
for (const { deviceKey, sessionId } of sessions) {
|
||||
const deviceSessions = this.sessions[deviceKey] || {};
|
||||
delete deviceSessions[sessionId];
|
||||
if (Object.keys(deviceSessions).length === 0) {
|
||||
// No more sessions for this device.
|
||||
delete this.sessions[deviceKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound Group Sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
@@ -445,6 +532,63 @@ export class MemoryCryptoStore implements CryptoStore {
|
||||
this.inboundGroupSessionsWithheld[k] = sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of Megolm sessions in the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async countEndToEndInboundGroupSessions(): Promise<number> {
|
||||
return Object.keys(this.inboundGroupSessions).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
|
||||
const result: SessionExtended[] = [];
|
||||
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
|
||||
result.push({
|
||||
senderKey: key.slice(0, 43),
|
||||
sessionId: key.slice(44),
|
||||
sessionData: session,
|
||||
needsBackup: key in this.sessionsNeedingBackup,
|
||||
});
|
||||
if (result.length >= SESSION_BATCH_SIZE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
// There are fewer sessions than the batch size; return the final batch of sessions.
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public async deleteEndToEndInboundGroupSessionsBatch(
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
for (const { senderKey, sessionId } of sessions) {
|
||||
const k = senderKey + "/" + sessionId;
|
||||
delete this.inboundGroupSessions[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Device Data
|
||||
|
||||
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
|
||||
|
||||
@@ -137,12 +137,15 @@ export class VerificationBase<
|
||||
if (this.transactionTimeoutTimer !== null) {
|
||||
clearTimeout(this.transactionTimeoutTimer);
|
||||
}
|
||||
this.transactionTimeoutTimer = setTimeout(() => {
|
||||
if (!this._done && !this.cancelled) {
|
||||
logger.info("Triggering verification timeout");
|
||||
this.cancel(timeoutException);
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10 minutes
|
||||
this.transactionTimeoutTimer = setTimeout(
|
||||
() => {
|
||||
if (!this._done && !this.cancelled) {
|
||||
logger.info("Triggering verification timeout");
|
||||
this.cancel(timeoutException);
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10 minutes
|
||||
}
|
||||
|
||||
private endTimer(): void {
|
||||
|
||||
@@ -39,7 +39,11 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
* @param roomId - id of the room where verification events should be posted in, should be a DM with the given user.
|
||||
* @param userId - id of user that the verification request is directed at, should be present in the room.
|
||||
*/
|
||||
public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {}
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
public readonly roomId: string,
|
||||
public userId?: string,
|
||||
) {}
|
||||
|
||||
public get receiveStartFromOtherDevices(): boolean {
|
||||
return true;
|
||||
|
||||
+8
-2
@@ -21,7 +21,10 @@ export enum InvalidStoreState {
|
||||
export class InvalidStoreError extends Error {
|
||||
public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
|
||||
|
||||
public constructor(public readonly reason: InvalidStoreState, public readonly value: any) {
|
||||
public constructor(
|
||||
public readonly reason: InvalidStoreState,
|
||||
public readonly value: any,
|
||||
) {
|
||||
const message =
|
||||
`Store is invalid because ${reason}, ` +
|
||||
`please stop the client, delete all data and start the client again`;
|
||||
@@ -47,7 +50,10 @@ export class InvalidCryptoStoreError extends Error {
|
||||
}
|
||||
|
||||
export class KeySignatureUploadError extends Error {
|
||||
public constructor(message: string, public readonly value: any) {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly value: any,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,10 @@ export interface IFilterComponent {
|
||||
* 'Filters' are referred to as 'FilterCollections'.
|
||||
*/
|
||||
export class FilterComponent {
|
||||
public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {}
|
||||
public constructor(
|
||||
private filterJson: IFilterComponent,
|
||||
public readonly userId?: string | undefined | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks with the filter component matches the given event
|
||||
|
||||
+4
-1
@@ -92,7 +92,10 @@ export class Filter {
|
||||
* @param userId - The user ID for this filter.
|
||||
* @param filterId - The filter ID if known.
|
||||
*/
|
||||
public constructor(public readonly userId: string | undefined | null, public filterId?: string) {}
|
||||
public constructor(
|
||||
public readonly userId: string | undefined | null,
|
||||
public filterId?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the ID of this filter on your homeserver (if known)
|
||||
|
||||
@@ -30,7 +30,10 @@ interface IErrorJson extends Partial<IUsageLimit> {
|
||||
* @param httpStatus - The HTTP response status code.
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
public constructor(msg: string, public readonly httpStatus?: number) {
|
||||
public constructor(
|
||||
msg: string,
|
||||
public readonly httpStatus?: number,
|
||||
) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ interface TypedResponse<T> extends Response {
|
||||
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
|
||||
? T
|
||||
: O extends { onlyData: true }
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
const abortController = opts.abortController ?? new AbortController();
|
||||
|
||||
// If the file doesn't have a mime type, use a default since the HS errors if we don't supply one.
|
||||
const contentType = opts.type ?? (file as File).type ?? "application/octet-stream";
|
||||
const contentType = (opts.type ?? (file as File).type) || "application/octet-stream";
|
||||
const fileName = opts.name ?? (file as File).name;
|
||||
|
||||
const upload = {
|
||||
|
||||
@@ -40,7 +40,7 @@ export enum MediaPrefix {
|
||||
/**
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
|
||||
*/
|
||||
V1 = "/_matrix/media/v3",
|
||||
V1 = "/_matrix/media/v1",
|
||||
/**
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
|
||||
*/
|
||||
|
||||
@@ -149,8 +149,12 @@ export type IAuthDict = AuthDict;
|
||||
export class NoAuthFlowFoundError extends Error {
|
||||
public name = "NoAuthFlowFoundError";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) {
|
||||
public constructor(
|
||||
m: string,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
|
||||
public readonly required_stages: string[],
|
||||
public readonly flows: UIAFlow[],
|
||||
) {
|
||||
super(m);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -29,7 +29,7 @@ export interface Logger extends BaseLogger {
|
||||
}
|
||||
|
||||
/** The basic interface for a logger which doesn't support children */
|
||||
interface BaseLogger {
|
||||
export interface BaseLogger {
|
||||
/**
|
||||
* Output trace message to the logger, with stack trace.
|
||||
*
|
||||
@@ -156,7 +156,10 @@ extendLogger(logger);
|
||||
export class LogSpan implements BaseLogger {
|
||||
private readonly name;
|
||||
|
||||
public constructor(private readonly parent: BaseLogger, name: string) {
|
||||
public constructor(
|
||||
private readonly parent: BaseLogger,
|
||||
name: string,
|
||||
) {
|
||||
this.name = name + ":";
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ export class CallMembership {
|
||||
return deepCompare(a.data, b.data);
|
||||
}
|
||||
|
||||
public constructor(private parentEvent: MatrixEvent, private data: CallMembershipData) {
|
||||
public constructor(
|
||||
private parentEvent: MatrixEvent,
|
||||
private data: CallMembershipData,
|
||||
) {
|
||||
if (typeof data.expires !== "number") throw new Error("Malformed membership: expires must be numeric");
|
||||
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
|
||||
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
|
||||
|
||||
@@ -133,7 +133,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
|
||||
if (eventMemberships === undefined) {
|
||||
logger.warn(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
|
||||
logger.debug(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(eventMemberships)) {
|
||||
|
||||
@@ -85,7 +85,10 @@ export enum TreePermissions {
|
||||
export class MSC3089TreeSpace {
|
||||
public readonly room: Room;
|
||||
|
||||
public constructor(private client: MatrixClient, public readonly roomId: string) {
|
||||
public constructor(
|
||||
private client: MatrixClient,
|
||||
public readonly roomId: string,
|
||||
) {
|
||||
this.room = this.client.getRoom(this.roomId)!;
|
||||
|
||||
if (!this.room) throw new Error("Unknown room");
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "./event";
|
||||
import { Room } from "./room";
|
||||
import { inMainTimelineForReceipt, threadIdForReceipt } from "../client";
|
||||
|
||||
/**
|
||||
* Determine the order of two events in a room.
|
||||
*
|
||||
* In principle this should use the same order as the server, but in practice
|
||||
* this is difficult for events that were not received over the Sync API. See
|
||||
* MSC4033 for details.
|
||||
*
|
||||
* This implementation leans on the order of events within their timelines, and
|
||||
* falls back to comparing event timestamps when they are in different
|
||||
* timelines.
|
||||
*
|
||||
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325 for where we are
|
||||
* tracking the work to fix this.
|
||||
*
|
||||
* @param room - the room we are looking in
|
||||
* @param leftEventId - the id of the first event
|
||||
* @param rightEventId - the id of the second event
|
||||
|
||||
* @returns -1 if left \< right, 1 if left \> right, 0 if left == right, null if
|
||||
* we can't tell (because we can't find the events).
|
||||
*/
|
||||
export function compareEventOrdering(room: Room, leftEventId: string, rightEventId: string): number | null {
|
||||
const leftEvent = room.findEventById(leftEventId);
|
||||
const rightEvent = room.findEventById(rightEventId);
|
||||
|
||||
if (!leftEvent || !rightEvent) {
|
||||
// Without the events themselves, we can't find their thread or
|
||||
// timeline, or guess based on timestamp, so we just don't know.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check whether the events are in the main timeline
|
||||
const isLeftEventInMainTimeline = inMainTimelineForReceipt(leftEvent);
|
||||
const isRightEventInMainTimeline = inMainTimelineForReceipt(rightEvent);
|
||||
|
||||
if (isLeftEventInMainTimeline && isRightEventInMainTimeline) {
|
||||
return compareEventsInMainTimeline(room, leftEventId, rightEventId, leftEvent, rightEvent);
|
||||
} else {
|
||||
// At least one event is not in the timeline, so we can't use the room's
|
||||
// unfiltered timeline set.
|
||||
return compareEventsInThreads(leftEventId, rightEventId, leftEvent, rightEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function compareEventsInMainTimeline(
|
||||
room: Room,
|
||||
leftEventId: string,
|
||||
rightEventId: string,
|
||||
leftEvent: MatrixEvent,
|
||||
rightEvent: MatrixEvent,
|
||||
): number | null {
|
||||
// Get the timeline set that contains all the events.
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
|
||||
// If they are in the same timeline, compareEventOrdering does what we need
|
||||
const compareSameTimeline = timelineSet.compareEventOrdering(leftEventId, rightEventId);
|
||||
if (compareSameTimeline !== null) {
|
||||
return compareSameTimeline;
|
||||
}
|
||||
|
||||
// Find which timeline each event is in. Refuse to provide an ordering if we
|
||||
// can't find either of the events.
|
||||
|
||||
const leftTimeline = timelineSet.getTimelineForEvent(leftEventId);
|
||||
if (leftTimeline === timelineSet.getLiveTimeline()) {
|
||||
// The left event is part of the live timeline, so it must be after the
|
||||
// right event (since they are not in the same timeline or we would have
|
||||
// returned after compareEventOrdering.
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rightTimeline = timelineSet.getTimelineForEvent(rightEventId);
|
||||
if (rightTimeline === timelineSet.getLiveTimeline()) {
|
||||
// The right event is part of the live timeline, so it must be after the
|
||||
// left event.
|
||||
return -1;
|
||||
}
|
||||
|
||||
// They are in older timeline sets (because they were fetched by paging up).
|
||||
return guessOrderBasedOnTimestamp(leftEvent, rightEvent);
|
||||
}
|
||||
|
||||
function compareEventsInThreads(
|
||||
leftEventId: string,
|
||||
rightEventId: string,
|
||||
leftEvent: MatrixEvent,
|
||||
rightEvent: MatrixEvent,
|
||||
): number | null {
|
||||
const leftEventThreadId = threadIdForReceipt(leftEvent);
|
||||
const rightEventThreadId = threadIdForReceipt(rightEvent);
|
||||
|
||||
const leftThread = leftEvent.getThread();
|
||||
|
||||
if (leftThread && leftEventThreadId === rightEventThreadId) {
|
||||
// They are in the same thread, so we can ask the thread's timeline to
|
||||
// figure it out for us
|
||||
return leftThread.timelineSet.compareEventOrdering(leftEventId, rightEventId);
|
||||
} else {
|
||||
return guessOrderBasedOnTimestamp(leftEvent, rightEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess the order of events based on server timestamp. This is not good, but
|
||||
* difficult to avoid without MSC4033.
|
||||
*
|
||||
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325
|
||||
*/
|
||||
function guessOrderBasedOnTimestamp(leftEvent: MatrixEvent, rightEvent: MatrixEvent): number {
|
||||
const leftTs = leftEvent.getTs();
|
||||
const rightTs = rightEvent.getTs();
|
||||
if (leftTs < rightTs) {
|
||||
return -1;
|
||||
} else if (leftTs > rightTs) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -839,7 +839,10 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
liveEvent: timeline == this.liveTimeline,
|
||||
// The purpose of this method is inserting events in the middle of the
|
||||
// timeline, so the events are, by definition, not live (whether or not
|
||||
// we're adding them to the live timeline).
|
||||
liveEvent: false,
|
||||
};
|
||||
this.emit(RoomEvent.Timeline, event, this.room, false, false, data);
|
||||
}
|
||||
@@ -899,11 +902,10 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
* @param eventId1 - The id of the first event
|
||||
* @param eventId2 - The id of the second event
|
||||
|
||||
* @returns a number less than zero if eventId1 precedes eventId2, and
|
||||
* greater than zero if eventId1 succeeds eventId2. zero if they are the
|
||||
* same event; null if we can't tell (either because we don't know about one
|
||||
* of the events, or because they are in separate timelines which don't join
|
||||
* up).
|
||||
* @returns -1 if eventId1 precedes eventId2, and +1 eventId1 succeeds
|
||||
* eventId2. 0 if they are the same event; null if we can't tell (either
|
||||
* because we don't know about one of the events, or because they are in
|
||||
* separate timelines which don't join up).
|
||||
*/
|
||||
public compareEventOrdering(eventId1: string, eventId2: string): number | null {
|
||||
if (eventId1 == eventId2) {
|
||||
@@ -935,7 +937,16 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
idx2 = idx;
|
||||
}
|
||||
}
|
||||
return idx1! - idx2!;
|
||||
const difference = idx1! - idx2!;
|
||||
|
||||
// Return the sign of difference.
|
||||
if (difference < 0) {
|
||||
return -1;
|
||||
} else if (difference > 0) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// the events are in different timelines. Iterate through the
|
||||
@@ -992,9 +1003,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
|
||||
if (!shouldLiveInRoom && !shouldLiveInThread) {
|
||||
logger.warn(
|
||||
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${
|
||||
this.room?.roomId
|
||||
} eventId=${event.getId()} threadId=${event.threadRootId}`,
|
||||
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${this.room
|
||||
?.roomId} eventId=${event.getId()} threadId=${event.threadRootId}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -75,7 +75,11 @@ export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, P
|
||||
*/
|
||||
private undecryptableRelationEventIds = new Set<string>();
|
||||
|
||||
public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
|
||||
public constructor(
|
||||
public readonly rootEvent: MatrixEvent,
|
||||
private matrixClient: MatrixClient,
|
||||
private room: Room,
|
||||
) {
|
||||
super();
|
||||
if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
|
||||
throw new Error("Invalid poll start event.");
|
||||
|
||||
+24
-36
@@ -27,15 +27,29 @@ import { EventTimelineSet } from "./event-timeline-set";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { NotificationCountType } from "./room";
|
||||
import { logger } from "../logger";
|
||||
import { inMainTimelineForReceipt, threadIdForReceipt } from "../client";
|
||||
|
||||
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
|
||||
/**
|
||||
* Create a synthetic receipt for the given event
|
||||
* @param userId - The user ID if the receipt sender
|
||||
* @param event - The event that is to be acknowledged
|
||||
* @param receiptType - The type of receipt
|
||||
* @param unthreaded - the receipt is unthreaded
|
||||
* @returns a new event with the synthetic receipt in it
|
||||
*/
|
||||
export function synthesizeReceipt(
|
||||
userId: string,
|
||||
event: MatrixEvent,
|
||||
receiptType: ReceiptType,
|
||||
unthreaded = false,
|
||||
): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[event.getId()!]: {
|
||||
[receiptType]: {
|
||||
[userId]: {
|
||||
ts: event.getTs(),
|
||||
thread_id: event.threadRootId ?? MAIN_ROOM_TIMELINE,
|
||||
...(!unthreaded && { thread_id: threadIdForReceipt(event) }),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -160,11 +174,8 @@ export abstract class ReadReceipt<
|
||||
// The receipt is for the main timeline: we check that the event is
|
||||
// in the main timeline.
|
||||
|
||||
// There are two ways to know an event is in the main timeline:
|
||||
// either it has no threadRootId, or it is a thread root.
|
||||
// (Note: it's a little odd because the thread root is in the main
|
||||
// timeline, but it still has a threadRootId.)
|
||||
const eventIsInMainTimeline = !event.threadRootId || event.isThreadRoot;
|
||||
// Check if the event is in the main timeline
|
||||
const eventIsInMainTimeline = inMainTimelineForReceipt(event);
|
||||
|
||||
if (eventIsInMainTimeline) {
|
||||
// The receipt is for the main timeline, and so is the event, so
|
||||
@@ -367,9 +378,10 @@ export abstract class ReadReceipt<
|
||||
* @param userId - The user ID if the receipt sender
|
||||
* @param e - The event that is to be acknowledged
|
||||
* @param receiptType - The type of receipt
|
||||
* @param unthreaded - the receipt is unthreaded
|
||||
*/
|
||||
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
|
||||
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
|
||||
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType, unthreaded = false): void {
|
||||
this.addReceipt(synthesizeReceipt(userId, e, receiptType, unthreaded), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,33 +407,7 @@ export abstract class ReadReceipt<
|
||||
* @param eventId - The event ID to check if the user read.
|
||||
* @returns True if the user has read the event, false otherwise.
|
||||
*/
|
||||
public hasUserReadEvent(userId: string, eventId: string): boolean {
|
||||
const readUpToId = this.getEventReadUpTo(userId, false);
|
||||
if (readUpToId === eventId) return true;
|
||||
|
||||
if (
|
||||
this.timeline?.length &&
|
||||
this.timeline[this.timeline.length - 1].getSender() &&
|
||||
this.timeline[this.timeline.length - 1].getSender() === userId
|
||||
) {
|
||||
// It doesn't matter where the event is in the timeline, the user has read
|
||||
// it because they've sent the latest event.
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = this.timeline?.length - 1; i >= 0; --i) {
|
||||
const ev = this.timeline[i];
|
||||
|
||||
// If we encounter the target event first, the user hasn't read it
|
||||
// however if we encounter the readUpToId first then the user has read
|
||||
// it. These rules apply because we're iterating bottom-up.
|
||||
if (ev.getId() === eventId) return false;
|
||||
if (ev.getId() === readUpToId) return true;
|
||||
}
|
||||
|
||||
// We don't know if the user has read it, so assume not.
|
||||
return false;
|
||||
}
|
||||
public abstract hasUserReadEvent(userId: string, eventId: string): boolean;
|
||||
|
||||
/**
|
||||
* Returns the most recent unthreaded receipt for a given user
|
||||
@@ -429,6 +415,8 @@ export abstract class ReadReceipt<
|
||||
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
|
||||
* or a user chooses to use private read receipts (or we have simply not received
|
||||
* a receipt from this user yet).
|
||||
*
|
||||
* @deprecated use `hasUserReadEvent` or `getEventReadUpTo` instead
|
||||
*/
|
||||
public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ export class RelationsContainer {
|
||||
// this.relations.get(parentEventId).get(relationType).get(relationEventType)
|
||||
private relations = new Map<string, Map<RelationType | string, Map<EventType | string, Relations>>>();
|
||||
|
||||
public constructor(private readonly client: MatrixClient, private readonly room?: Room) {}
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
private readonly room?: Room,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a collection of child events to a given event in this timeline set.
|
||||
|
||||
@@ -140,7 +140,10 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
* @param roomId - The room ID of the member.
|
||||
* @param userId - The user ID of the member.
|
||||
*/
|
||||
public constructor(public readonly roomId: string, public readonly userId: string) {
|
||||
public constructor(
|
||||
public readonly roomId: string,
|
||||
public readonly userId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.name = userId;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
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 { MAIN_ROOM_TIMELINE, Receipt, ReceiptContent } from "../@types/read_receipts";
|
||||
import { threadIdForReceipt } from "../client";
|
||||
import { Room, RoomEvent } from "./room";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { logger } from "../logger";
|
||||
|
||||
/**
|
||||
* The latest receipts we have for a room.
|
||||
*/
|
||||
export class RoomReceipts {
|
||||
private room: Room;
|
||||
private threadedReceipts: ThreadedReceipts;
|
||||
private unthreadedReceipts: ReceiptsByUser;
|
||||
private danglingReceipts: DanglingReceipts;
|
||||
|
||||
public constructor(room: Room) {
|
||||
this.room = room;
|
||||
this.threadedReceipts = new ThreadedReceipts(room);
|
||||
this.unthreadedReceipts = new ReceiptsByUser(room);
|
||||
this.danglingReceipts = new DanglingReceipts();
|
||||
// We listen for timeline events so we can process dangling receipts
|
||||
room.on(RoomEvent.Timeline, this.onTimelineEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the receipt information supplied. For each receipt:
|
||||
*
|
||||
* If we don't have the event for this receipt, store it as "dangling" so we
|
||||
* can process it later.
|
||||
*
|
||||
* Otherwise store it per-user in either the threaded store for its
|
||||
* thread_id, or the unthreaded store if there is no thread_id.
|
||||
*
|
||||
* Ignores any receipt that is before an existing receipt for the same user
|
||||
* (in the same thread, if applicable). "Before" is defined by the
|
||||
* unfilteredTimelineSet of the room.
|
||||
*/
|
||||
public add(receiptContent: ReceiptContent, synthetic: boolean): void {
|
||||
/*
|
||||
Transform this structure:
|
||||
{
|
||||
"$EVENTID": {
|
||||
"m.read|m.read.private": {
|
||||
"@user:example.org": {
|
||||
"ts": 1661,
|
||||
"thread_id": "main|$THREAD_ROOT_ID" // or missing/undefined for an unthreaded receipt
|
||||
}
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
into maps of:
|
||||
threaded :: threadid :: userId :: ReceiptInfo
|
||||
unthreaded :: userId :: ReceiptInfo
|
||||
dangling :: eventId :: DanglingReceipt
|
||||
*/
|
||||
for (const [eventId, eventReceipt] of Object.entries(receiptContent)) {
|
||||
for (const [receiptType, receiptsByUser] of Object.entries(eventReceipt)) {
|
||||
for (const [userId, receipt] of Object.entries(receiptsByUser)) {
|
||||
const referencedEvent = this.room.findEventById(eventId);
|
||||
if (!referencedEvent) {
|
||||
this.danglingReceipts.add(
|
||||
new DanglingReceipt(eventId, receiptType, userId, receipt, synthetic),
|
||||
);
|
||||
} else if (receipt.thread_id) {
|
||||
this.threadedReceipts.set(
|
||||
receipt.thread_id,
|
||||
eventId,
|
||||
receiptType,
|
||||
userId,
|
||||
receipt.ts,
|
||||
synthetic,
|
||||
);
|
||||
} else {
|
||||
this.unthreadedReceipts.set(eventId, receiptType, userId, receipt.ts, synthetic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for dangling receipts for the given event ID,
|
||||
* and add them to the thread of unthread receipts if found.
|
||||
* @param eventId - the event ID to look for
|
||||
*/
|
||||
private onTimelineEvent = (event: MatrixEvent): void => {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) return;
|
||||
|
||||
const danglingReceipts = this.danglingReceipts.remove(eventId);
|
||||
|
||||
danglingReceipts?.forEach((danglingReceipt) => {
|
||||
// The receipt is a thread receipt
|
||||
if (danglingReceipt.receipt.thread_id) {
|
||||
this.threadedReceipts.set(
|
||||
danglingReceipt.receipt.thread_id,
|
||||
danglingReceipt.eventId,
|
||||
danglingReceipt.receiptType,
|
||||
danglingReceipt.userId,
|
||||
danglingReceipt.receipt.ts,
|
||||
danglingReceipt.synthetic,
|
||||
);
|
||||
} else {
|
||||
this.unthreadedReceipts.set(
|
||||
eventId,
|
||||
danglingReceipt.receiptType,
|
||||
danglingReceipt.userId,
|
||||
danglingReceipt.receipt.ts,
|
||||
danglingReceipt.synthetic,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
public hasUserReadEvent(userId: string, eventId: string): boolean {
|
||||
const unthreaded = this.unthreadedReceipts.get(userId);
|
||||
if (unthreaded) {
|
||||
if (isAfterOrSame(unthreaded.eventId, eventId, this.room)) {
|
||||
// The unthreaded receipt is after this event, so we have read it.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const event = this.room.findEventById(eventId);
|
||||
if (!event) {
|
||||
// We don't know whether the user has read it - default to caution and say no.
|
||||
// This shouldn't really happen and feels like it ought to be an exception: let's
|
||||
// log a warn for now.
|
||||
logger.warn(
|
||||
`hasUserReadEvent event ID ${eventId} not found in room ${this.room.roomId}: this shouldn't happen!`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const threadId = threadIdForReceipt(event);
|
||||
const threaded = this.threadedReceipts.get(threadId, userId);
|
||||
if (threaded) {
|
||||
if (isAfterOrSame(threaded.eventId, eventId, this.room)) {
|
||||
// The threaded receipt is after this event, so we have read it.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: what if they sent the second-last event in the thread?
|
||||
if (this.userSentLatestEventInThread(threadId, userId)) {
|
||||
// The user sent the latest message in this event's thread, so we
|
||||
// consider everything in the thread to be read.
|
||||
//
|
||||
// Note: maybe we don't need this because synthetic receipts should
|
||||
// do this job for us?
|
||||
return true;
|
||||
}
|
||||
|
||||
// Neither of the receipts were after the event, so it's unread.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the thread with this ID can be found, and the supplied
|
||||
* user sent the latest message in it.
|
||||
*/
|
||||
private userSentLatestEventInThread(threadId: string, userId: String): boolean {
|
||||
const timeline =
|
||||
threadId === MAIN_ROOM_TIMELINE
|
||||
? this.room.getLiveTimeline().getEvents()
|
||||
: this.room.getThread(threadId)?.timeline;
|
||||
|
||||
return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
/**
|
||||
* The information "inside" a receipt once it has been stored inside
|
||||
* RoomReceipts - what eventId it refers to, its type, and its ts.
|
||||
*
|
||||
* Does not contain userId or threadId since these are stored as keys of the
|
||||
* maps in RoomReceipts.
|
||||
*/
|
||||
class ReceiptInfo {
|
||||
public constructor(
|
||||
public eventId: string,
|
||||
public receiptType: string,
|
||||
public ts: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything we know about a receipt that is "dangling" because we can't find
|
||||
* the event to which it refers.
|
||||
*/
|
||||
class DanglingReceipt {
|
||||
public constructor(
|
||||
public eventId: string,
|
||||
public receiptType: string,
|
||||
public userId: string,
|
||||
public receipt: Receipt,
|
||||
public synthetic: boolean,
|
||||
) {}
|
||||
}
|
||||
|
||||
class UserReceipts {
|
||||
private room: Room;
|
||||
|
||||
/**
|
||||
* The real receipt for this user.
|
||||
*/
|
||||
private real: ReceiptInfo | undefined;
|
||||
|
||||
/**
|
||||
* The synthetic receipt for this user. If this is defined, it is later than real.
|
||||
*/
|
||||
private synthetic: ReceiptInfo | undefined;
|
||||
|
||||
public constructor(room: Room) {
|
||||
this.room = room;
|
||||
this.real = undefined;
|
||||
this.synthetic = undefined;
|
||||
}
|
||||
|
||||
public set(synthetic: boolean, receiptInfo: ReceiptInfo): void {
|
||||
if (synthetic) {
|
||||
this.synthetic = receiptInfo;
|
||||
} else {
|
||||
this.real = receiptInfo;
|
||||
}
|
||||
|
||||
// Preserve the invariant: synthetic is only defined if it's later than real
|
||||
if (this.synthetic && this.real) {
|
||||
if (isAfterOrSame(this.real.eventId, this.synthetic.eventId, this.room)) {
|
||||
this.synthetic = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest receipt we have - synthetic if we have one (and it's
|
||||
* later), otherwise real.
|
||||
*/
|
||||
public get(): ReceiptInfo | undefined {
|
||||
// Relies on the invariant that synthetic is only defined if it's later than real.
|
||||
return this.synthetic ?? this.real;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest receipt we have of the specified type (synthetic or not).
|
||||
*/
|
||||
public getByType(synthetic: boolean): ReceiptInfo | undefined {
|
||||
return synthetic ? this.synthetic : this.real;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest receipt info we have, either for a single thread, or all the
|
||||
* unthreaded receipts for a room.
|
||||
*
|
||||
* userId: ReceiptInfo
|
||||
*/
|
||||
class ReceiptsByUser {
|
||||
private room: Room;
|
||||
|
||||
/** map of userId: UserReceipts */
|
||||
private data: Map<String, UserReceipts>;
|
||||
|
||||
public constructor(room: Room) {
|
||||
this.room = room;
|
||||
this.data = new Map<string, UserReceipts>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the supplied receipt to our structure, if it is not earlier than the
|
||||
* one we already hold for this user.
|
||||
*/
|
||||
public set(eventId: string, receiptType: string, userId: string, ts: number, synthetic: boolean): void {
|
||||
const userReceipts = getOrCreate(this.data, userId, () => new UserReceipts(this.room));
|
||||
|
||||
const existingReceipt = userReceipts.getByType(synthetic);
|
||||
if (existingReceipt && isAfter(existingReceipt.eventId, eventId, this.room)) {
|
||||
// The new receipt is before the existing one - don't store it.
|
||||
return;
|
||||
}
|
||||
|
||||
// Possibilities:
|
||||
//
|
||||
// 1. there was no existing receipt, or
|
||||
// 2. the existing receipt was before this one, or
|
||||
// 3. we were unable to compare the receipts.
|
||||
//
|
||||
// In the case of 3 it's difficult to decide what to do, so the
|
||||
// most-recently-received receipt wins.
|
||||
//
|
||||
// Case 3 can only happen if the events for these receipts have
|
||||
// disappeared, which is quite unlikely since the new one has just been
|
||||
// checked, and the old one was checked before it was inserted here.
|
||||
//
|
||||
// We go ahead and store this receipt (replacing the other if it exists)
|
||||
userReceipts.set(synthetic, new ReceiptInfo(eventId, receiptType, ts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest receipt we have for this user. (Note - there is only one
|
||||
* receipt per user, because we are already inside a specific thread or
|
||||
* unthreaded list.)
|
||||
*
|
||||
* If there is a later synthetic receipt for this user, return that.
|
||||
* Otherwise, return the real receipt.
|
||||
*
|
||||
* @returns the found receipt info, or undefined if we have no receipt for this user.
|
||||
*/
|
||||
public get(userId: string): ReceiptInfo | undefined {
|
||||
return this.data.get(userId)?.get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest threaded receipts we have for a room.
|
||||
*/
|
||||
class ThreadedReceipts {
|
||||
private room: Room;
|
||||
|
||||
/** map of threadId: ReceiptsByUser */
|
||||
private data: Map<string, ReceiptsByUser>;
|
||||
|
||||
public constructor(room: Room) {
|
||||
this.room = room;
|
||||
this.data = new Map<string, ReceiptsByUser>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the supplied receipt to our structure, if it is not earlier than one
|
||||
* we already hold for this user in this thread.
|
||||
*/
|
||||
public set(
|
||||
threadId: string,
|
||||
eventId: string,
|
||||
receiptType: string,
|
||||
userId: string,
|
||||
ts: number,
|
||||
synthetic: boolean,
|
||||
): void {
|
||||
const receiptsByUser = getOrCreate(this.data, threadId, () => new ReceiptsByUser(this.room));
|
||||
receiptsByUser.set(eventId, receiptType, userId, ts, synthetic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest threaded receipt for the supplied user in the supplied thread.
|
||||
*
|
||||
* @returns the found receipt info or undefined if we don't have one.
|
||||
*/
|
||||
public get(threadId: string, userId: string): ReceiptInfo | undefined {
|
||||
return this.data.get(threadId)?.get(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All the receipts that we have received but can't process because we can't
|
||||
* find the event they refer to.
|
||||
*
|
||||
* We hold on to them so we can process them if their event arrives later.
|
||||
*/
|
||||
class DanglingReceipts {
|
||||
/**
|
||||
* eventId: DanglingReceipt[]
|
||||
*/
|
||||
private data = new Map<string, Array<DanglingReceipt>>();
|
||||
|
||||
/**
|
||||
* Remember the supplied dangling receipt.
|
||||
*/
|
||||
public add(danglingReceipt: DanglingReceipt): void {
|
||||
const danglingReceipts = getOrCreate(this.data, danglingReceipt.eventId, () => []);
|
||||
danglingReceipts.push(danglingReceipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the dangling receipts for the given event ID.
|
||||
* @param eventId - the event ID to look for
|
||||
* @returns the found dangling receipts, or undefined if we don't have one.
|
||||
*/
|
||||
public remove(eventId: string): Array<DanglingReceipt> | undefined {
|
||||
const danglingReceipts = this.data.get(eventId);
|
||||
this.data.delete(eventId);
|
||||
return danglingReceipts;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreate<K, V>(m: Map<K, V>, key: K, createFn: () => V): V {
|
||||
const found = m.get(key);
|
||||
if (found) {
|
||||
return found;
|
||||
} else {
|
||||
const created = createFn();
|
||||
m.set(key, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is left after right (or the same)?
|
||||
*
|
||||
* Only returns true if both events can be found, and left is after or the same
|
||||
* as right.
|
||||
*
|
||||
* @returns left \>= right
|
||||
*/
|
||||
function isAfterOrSame(leftEventId: string, rightEventId: string, room: Room): boolean {
|
||||
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
|
||||
return comparison !== null && comparison >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is left strictly after right?
|
||||
*
|
||||
* Only returns true if both events can be found, and left is strictly after right.
|
||||
*
|
||||
* @returns left \> right
|
||||
*/
|
||||
function isAfter(leftEventId: string, rightEventId: string, room: Room): boolean {
|
||||
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
|
||||
return comparison !== null && comparison > 0;
|
||||
}
|
||||
@@ -187,7 +187,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
* and shared when the room state is cloned for the new timeline.
|
||||
* This should only be passed from clone.
|
||||
*/
|
||||
public constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) {
|
||||
public constructor(
|
||||
public readonly roomId: string,
|
||||
private oobMemberFlags = { status: OobStatus.NotStarted },
|
||||
) {
|
||||
super();
|
||||
this.updateModifiedTime();
|
||||
}
|
||||
@@ -778,7 +781,9 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
// The user may have been the sender, but they can't redact their own message
|
||||
// if redactions are blocked.
|
||||
const canRedact = this.maySendEvent(EventType.RoomRedaction, userId);
|
||||
if (mxEvent.getSender() === userId) return canRedact;
|
||||
|
||||
if (!canRedact) return false;
|
||||
if (mxEvent.getSender() === userId) return true;
|
||||
|
||||
return this.hasSufficientPowerLevelFor("redact", member.powerLevel);
|
||||
}
|
||||
|
||||
@@ -40,5 +40,8 @@ interface IInfo {
|
||||
* @param info - Optional. The summary info. Additional keys are supported.
|
||||
*/
|
||||
export class RoomSummary {
|
||||
public constructor(public readonly roomId: string, info?: IInfo) {}
|
||||
public constructor(
|
||||
public readonly roomId: string,
|
||||
info?: IInfo,
|
||||
) {}
|
||||
}
|
||||
|
||||
+58
-1
@@ -66,6 +66,8 @@ import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
|
||||
import { isPollEvent, Poll, PollEvent } from "./poll";
|
||||
import { RoomReceipts } from "./room-receipts";
|
||||
import { compareEventOrdering } from "./compare-event-ordering";
|
||||
|
||||
// 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
|
||||
@@ -432,6 +434,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
private visibilityEvents = new Map<string, MatrixEvent[]>();
|
||||
|
||||
/**
|
||||
* The latest receipts (synthetic and real) for each user in each thread
|
||||
* (and unthreaded).
|
||||
*/
|
||||
private roomReceipts = new RoomReceipts(this);
|
||||
|
||||
/**
|
||||
* Construct a new Room.
|
||||
*
|
||||
@@ -1971,6 +1979,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
this.on(ThreadEvent.NewReply, this.onThreadReply);
|
||||
this.on(ThreadEvent.Update, this.onThreadUpdate);
|
||||
this.on(ThreadEvent.Delete, this.onThreadDelete);
|
||||
this.threadsReady = true;
|
||||
}
|
||||
@@ -2074,6 +2083,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
private onThreadUpdate(thread: Thread): void {
|
||||
this.updateThreadRootEvents(thread, false, false);
|
||||
}
|
||||
|
||||
private onThreadReply(thread: Thread): void {
|
||||
this.updateThreadRootEvents(thread, false, true);
|
||||
}
|
||||
@@ -2321,7 +2334,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.lastThread = thread;
|
||||
}
|
||||
|
||||
if (this.threadsReady) {
|
||||
// We need to update the thread root events, but the thread may not be ready yet.
|
||||
// If it isn't, it will fire ThreadEvent.Update when it is and we'll call updateThreadRootEvents then.
|
||||
if (this.threadsReady && thread.initialEventsFetched) {
|
||||
this.updateThreadRootEvents(thread, toStartOfTimeline, false);
|
||||
}
|
||||
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
|
||||
@@ -2935,6 +2950,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
public addReceipt(event: MatrixEvent, synthetic = false): void {
|
||||
const content = event.getContent<ReceiptContent>();
|
||||
|
||||
this.roomReceipts.add(content, synthetic);
|
||||
|
||||
// TODO: delete the following code when it has been replaced by RoomReceipts
|
||||
Object.keys(content).forEach((eventId: string) => {
|
||||
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
|
||||
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
|
||||
@@ -2996,6 +3015,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
});
|
||||
});
|
||||
});
|
||||
// End of code to delete when replaced by RoomReceipts
|
||||
|
||||
// send events after we've regenerated the structure & cache, otherwise things that
|
||||
// listened for the event would read stale data.
|
||||
@@ -3582,6 +3602,19 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.oldestThreadedReceiptTs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given user has read a particular event ID with the known
|
||||
* history of the room. This is not a definitive check as it relies only on
|
||||
* what is available to the room at the time of execution.
|
||||
*
|
||||
* @param userId - The user ID to check the read state of.
|
||||
* @param eventId - The event ID to check if the user read.
|
||||
* @returns true if the user has read the event, false otherwise.
|
||||
*/
|
||||
public hasUserReadEvent(userId: string, eventId: string): boolean {
|
||||
return this.roomReceipts.hasUserReadEvent(userId, eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent unthreaded receipt for a given user
|
||||
* @param userId - the MxID of the User
|
||||
@@ -3615,6 +3648,30 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
thread.fixupNotifications(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the order of two events in this room.
|
||||
*
|
||||
* In principle this should use the same order as the server, but in practice
|
||||
* this is difficult for events that were not received over the Sync API. See
|
||||
* MSC4033 for details.
|
||||
*
|
||||
* This implementation leans on the order of events within their timelines, and
|
||||
* falls back to comparing event timestamps when they are in different
|
||||
* timelines.
|
||||
*
|
||||
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325 for where we are
|
||||
* tracking the work to fix this.
|
||||
*
|
||||
* @param leftEventId - the id of the first event
|
||||
* @param rightEventId - the id of the second event
|
||||
|
||||
* @returns -1 if left \< right, 1 if left \> right, 0 if left == right, null if
|
||||
* we can't tell (because we can't find the events).
|
||||
*/
|
||||
public compareEventOrdering(leftEventId: string, rightEventId: string): number | null {
|
||||
return compareEventOrdering(this, leftEventId, rightEventId);
|
||||
}
|
||||
}
|
||||
|
||||
// a map from current event status to a list of allowed next statuses
|
||||
|
||||
@@ -50,5 +50,8 @@ export class SearchResult {
|
||||
* @param context - the matching event and its
|
||||
* context
|
||||
*/
|
||||
public constructor(public readonly rank: number, public readonly context: EventContext) {}
|
||||
public constructor(
|
||||
public readonly rank: number,
|
||||
public readonly context: EventContext,
|
||||
) {}
|
||||
}
|
||||
|
||||
+88
-54
@@ -133,14 +133,24 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
private readonly pendingEventOrdering: PendingEventOrdering;
|
||||
private processRootEventPromise?: Promise<void>;
|
||||
|
||||
/**
|
||||
* Whether or not we need to fetch the initial set of events for the thread. We can
|
||||
* only do this if the server has support for it, so if it doesn't we just pretend
|
||||
* that we've already fetched them.
|
||||
*/
|
||||
public initialEventsFetched = !Thread.hasServerSideSupport;
|
||||
|
||||
/**
|
||||
* An array of events to add to the timeline once the thread has been initialised
|
||||
* with server suppport.
|
||||
*/
|
||||
public replayEvents: MatrixEvent[] | null = [];
|
||||
|
||||
public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) {
|
||||
public constructor(
|
||||
public readonly id: string,
|
||||
public rootEvent: MatrixEvent | undefined,
|
||||
opts: IThreadOpts,
|
||||
) {
|
||||
super();
|
||||
|
||||
// each Event in the thread adds a reemitter, so we could hit the listener limit.
|
||||
@@ -359,7 +369,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* to the start (and not the end) of the timeline.
|
||||
* @param emit - whether to emit the Update event if the thread was updated or not.
|
||||
*/
|
||||
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
|
||||
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void {
|
||||
// Modify this event to point at our room's state, and mark its thread
|
||||
// as this.
|
||||
this.setEventMetadata(event);
|
||||
@@ -378,56 +388,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
this.addEventToTimeline(event, false);
|
||||
this.fetchEditsWhereNeeded(event);
|
||||
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
|
||||
// If this event is not a direct member of the thread, but is a
|
||||
// reference to something that is, then we have two cases:
|
||||
|
||||
if (!this.initialEventsFetched) {
|
||||
// Case 1: we haven't yet fetched events from the server. In
|
||||
// this case, when we do, the events we get back might only be
|
||||
// the first-order ones, so this event (which is second-order -
|
||||
// a reference to something directly in the thread) needs to be
|
||||
// kept so we can replay it when the first-order ones turn up.
|
||||
|
||||
/**
|
||||
* A thread can be fully discovered via a single sync response
|
||||
* And when that's the case we still ask the server to do an initialisation
|
||||
* as it's the safest to ensure we have everything.
|
||||
* However when we are in that scenario we might loose annotation or edits
|
||||
*
|
||||
* This fix keeps a reference to those events and replay them once the thread
|
||||
* has been initialised properly.
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
// Case 2: this is happening later, and we have a timeline. In
|
||||
// this case, these events might be out-of order.
|
||||
//
|
||||
// Specifically, if the server doesn't support recursion, so we
|
||||
// only get these events through sync, they might be coming
|
||||
// later than the first-order ones, so we insert them based on
|
||||
// timestamp (despite the problems with this documented in
|
||||
// #3325).
|
||||
//
|
||||
// If the server does support recursion, we should have got all
|
||||
// the interspersed events from the server when we fetched the
|
||||
// initial events, so if they are coming via sync they should be
|
||||
// the latest ones, so we can add them as normal.
|
||||
//
|
||||
// (Note that both insertEventIntoTimeline and addEventToTimeline
|
||||
// do nothing if we have seen this event before.)
|
||||
|
||||
const recursionSupport =
|
||||
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
this.insertEventIntoTimeline(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
// Apply annotations and replace relations to the relations of the timeline only
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
|
||||
this.addRelatedThreadEvent(event, toStartOfTimeline);
|
||||
return;
|
||||
} else if (this.initialEventsFetched) {
|
||||
// If initial events have not been fetched, we are OK to throw away
|
||||
@@ -464,6 +425,59 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
}
|
||||
}
|
||||
|
||||
private addRelatedThreadEvent(event: MatrixEvent, toStartOfTimeline: boolean): void {
|
||||
// If this event is not a direct member of the thread, but is a
|
||||
// reference to something that is, then we have two cases:
|
||||
|
||||
if (!this.initialEventsFetched) {
|
||||
// Case 1: we haven't yet fetched events from the server. In
|
||||
// this case, when we do, the events we get back might only be
|
||||
// the first-order ones, so this event (which is second-order -
|
||||
// a reference to something directly in the thread) needs to be
|
||||
// kept so we can replay it when the first-order ones turn up.
|
||||
|
||||
/**
|
||||
* A thread can be fully discovered via a single sync response
|
||||
* And when that's the case we still ask the server to do an initialisation
|
||||
* as it's the safest to ensure we have everything.
|
||||
* However when we are in that scenario we might loose annotation or edits
|
||||
*
|
||||
* This fix keeps a reference to those events and replay them once the thread
|
||||
* has been initialised properly.
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
// Case 2: this is happening later, and we have a timeline. In
|
||||
// this case, these events might be out-of order.
|
||||
//
|
||||
// Specifically, if the server doesn't support recursion, so we
|
||||
// only get these events through sync, they might be coming
|
||||
// later than the first-order ones, so we insert them based on
|
||||
// timestamp (despite the problems with this documented in
|
||||
// #3325).
|
||||
//
|
||||
// If the server does support recursion, we should have got all
|
||||
// the interspersed events from the server when we fetched the
|
||||
// initial events, so if they are coming via sync they should be
|
||||
// the latest ones, so we can add them as normal.
|
||||
//
|
||||
// (Note that both insertEventIntoTimeline and addEventToTimeline
|
||||
// do nothing if we have seen this event before.)
|
||||
|
||||
const recursionSupport =
|
||||
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
this.insertEventIntoTimeline(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
// Apply annotations and replace relations to the relations of the timeline only
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
|
||||
}
|
||||
|
||||
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
|
||||
if (event) {
|
||||
this.setEventMetadata(event);
|
||||
@@ -609,7 +623,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
} else {
|
||||
await this.client.paginateEventTimeline(this.liveTimeline, {
|
||||
backwards: true,
|
||||
limit: Math.max(1, this.length),
|
||||
});
|
||||
}
|
||||
for (const event of this.replayEvents!) {
|
||||
@@ -748,6 +761,27 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* @returns ID of the latest event that the given user has read, or null.
|
||||
*/
|
||||
public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
|
||||
// TODO: we think the implementation here is not right. Here is a sketch
|
||||
// of the right answer:
|
||||
//
|
||||
// for event in timeline.events.reversed():
|
||||
// if room.hasUserReadEvent(event):
|
||||
// return event
|
||||
// return null
|
||||
//
|
||||
// If this is too slow, we might be able to improve it by trying walking
|
||||
// forward from the threaded receipt in this thread. We could alternate
|
||||
// between backwards-from-front and forwards-from-threaded-receipt to
|
||||
// improve our chances of hitting the right answer sooner.
|
||||
//
|
||||
// Either way, it's still fundamentally slow because we have to walk
|
||||
// events.
|
||||
//
|
||||
// We also might just want to limit the time we spend on this by giving
|
||||
// up after, say, 100 events.
|
||||
//
|
||||
// --- andyb
|
||||
|
||||
const isCurrentUser = userId === this.client.getUserId();
|
||||
const lastReply = this.timeline[this.timeline.length - 1];
|
||||
if (isCurrentUser && lastReply) {
|
||||
@@ -816,7 +850,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
}
|
||||
}
|
||||
|
||||
return super.hasUserReadEvent(userId, eventId);
|
||||
return this.room.hasUserReadEvent(userId, eventId);
|
||||
}
|
||||
|
||||
public setUnread(type: NotificationCountType, count: number): void {
|
||||
|
||||
@@ -44,8 +44,8 @@ type EventEmitterErrorListener = (error: Error) => void;
|
||||
export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E
|
||||
? A[T]
|
||||
: T extends EventEmitterEvents
|
||||
? EventEmitterErrorListener
|
||||
: EventEmitterEventListener;
|
||||
? EventEmitterErrorListener
|
||||
: EventEmitterEventListener;
|
||||
|
||||
/**
|
||||
* Typed Event Emitter class which can act as a Base Model for all our model
|
||||
|
||||
@@ -186,7 +186,7 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
|
||||
refresh_token: response.refresh_token,
|
||||
access_token: response.access_token,
|
||||
token_type: "Bearer",
|
||||
} as BearerTokenResponse);
|
||||
}) as BearerTokenResponse;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
|
||||
@@ -17,7 +17,10 @@ limitations under the License.
|
||||
import { RendezvousFailureReason } from ".";
|
||||
|
||||
export class RendezvousError extends Error {
|
||||
public constructor(message: string, public readonly code: RendezvousFailureReason) {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly code: RendezvousFailureReason,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CrossSigningIdentity {
|
||||
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
|
||||
|
||||
// Log all relevant state for easier parsing of debug logs.
|
||||
logger.log("bootStrapCrossSigning: starting", {
|
||||
logger.log("bootstrapCrossSigning: starting", {
|
||||
setupNewCrossSigning: opts.setupNewCrossSigning,
|
||||
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
|
||||
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
|
||||
@@ -66,18 +66,25 @@ export class CrossSigningIdentity {
|
||||
});
|
||||
|
||||
if (olmDeviceHasKeys) {
|
||||
if (!privateKeysInSecretStorage) {
|
||||
if (!(await this.secretStorage.hasKey())) {
|
||||
logger.warn(
|
||||
"bootstrapCrossSigning: Olm device has private keys, but secret storage is not yet set up; doing nothing for now.",
|
||||
);
|
||||
// the keys should get uploaded to 4S once that is set up.
|
||||
} else if (!privateKeysInSecretStorage) {
|
||||
// the device has the keys but they are not in 4S, so update it
|
||||
logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
logger.log("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
} else {
|
||||
logger.log("bootStrapCrossSigning: Olm device has private keys and they are saved in 4S, do nothing");
|
||||
logger.log(
|
||||
"bootstrapCrossSigning: Olm device has private keys and they are saved in secret storage; doing nothing",
|
||||
);
|
||||
}
|
||||
} /* (!olmDeviceHasKeys) */ else {
|
||||
if (privateKeysInSecretStorage) {
|
||||
// they are in 4S, so import from there
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"bootstrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"in secret storage, reading storage and caching locally",
|
||||
);
|
||||
await this.olmMachine.importCrossSigningKeys(
|
||||
@@ -100,7 +107,7 @@ export class CrossSigningIdentity {
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
|
||||
"bootstrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
|
||||
);
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
}
|
||||
@@ -108,7 +115,7 @@ export class CrossSigningIdentity {
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
|
||||
logger.log("bootStrapCrossSigning: complete");
|
||||
logger.log("bootstrapCrossSigning: complete");
|
||||
}
|
||||
|
||||
/** Reset our cross-signing keys
|
||||
@@ -123,14 +130,21 @@ export class CrossSigningIdentity {
|
||||
// or 4S passphrase/key the process will fail in a bad state, with keys rotated but not uploaded or saved in 4S.
|
||||
const outgoingRequests: CrossSigningBootstrapRequests = await this.olmMachine.bootstrapCrossSigning(true);
|
||||
|
||||
// If 4S is configured we need to udpate it.
|
||||
if (await this.secretStorage.hasKey()) {
|
||||
// If 4S is configured we need to update it.
|
||||
if (!(await this.secretStorage.hasKey())) {
|
||||
logger.warn(
|
||||
"resetCrossSigning: Secret storage is not yet set up; not exporting keys to secret storage yet.",
|
||||
);
|
||||
// the keys should get uploaded to 4S once that is set up.
|
||||
} else {
|
||||
// Update 4S before uploading cross-signing keys, to stay consistent with legacy that asks
|
||||
// 4S passphrase before asking for account password.
|
||||
// Ultimately should be made atomic and resistent to forgotten password/passphrase.
|
||||
// Ultimately should be made atomic and resistant to forgotten password/passphrase.
|
||||
logger.log("resetCrossSigning: exporting to secret storage");
|
||||
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
}
|
||||
logger.log("bootStrapCrossSigning: publishing keys to server");
|
||||
logger.log("resetCrossSigning: publishing keys to server");
|
||||
for (const req of [
|
||||
outgoingRequests.uploadKeysRequest,
|
||||
outgoingRequests.uploadSigningKeysRequest,
|
||||
@@ -151,17 +165,17 @@ export class CrossSigningIdentity {
|
||||
const exported: RustSdkCryptoJs.CrossSigningKeyExport | null = await this.olmMachine.exportCrossSigningKeys();
|
||||
/* istanbul ignore else (this function is only called when we know the olm machine has keys) */
|
||||
if (exported?.masterKey) {
|
||||
this.secretStorage.store("m.cross_signing.master", exported.masterKey);
|
||||
await this.secretStorage.store("m.cross_signing.master", exported.masterKey);
|
||||
} else {
|
||||
logger.error(`Cannot export MSK to secret storage, private key unknown`);
|
||||
}
|
||||
if (exported?.self_signing_key) {
|
||||
this.secretStorage.store("m.cross_signing.self_signing", exported.self_signing_key);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", exported.self_signing_key);
|
||||
} else {
|
||||
logger.error(`Cannot export SSK to secret storage, private key unknown`);
|
||||
}
|
||||
if (exported?.userSigningKey) {
|
||||
this.secretStorage.store("m.cross_signing.user_signing", exported.userSigningKey);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", exported.userSigningKey);
|
||||
} else {
|
||||
logger.error(`Cannot export USK to secret storage, private key unknown`);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ export class KeyClaimManager {
|
||||
throw new Error(`Cannot ensure Olm sessions: shutting down`);
|
||||
}
|
||||
logger.info("Checking for missing Olm sessions");
|
||||
const claimRequest = await this.olmMachine.getMissingSessions(userList);
|
||||
// By passing the userId array to rust we transfer ownership of the items to rust, causing
|
||||
// them to be invalidated on the JS side as soon as the method is called.
|
||||
// As we haven't created the `userList` let's clone the users, to not break the caller from re-using it.
|
||||
const claimRequest = await this.olmMachine.getMissingSessions(userList.map((u) => u.clone()));
|
||||
if (claimRequest) {
|
||||
logger.info("Making /keys/claim request");
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user