Compare commits
64 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 |
+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,13 +11,11 @@ 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
|
||||
|
||||
|
||||
@@ -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.85.0
|
||||
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,5 +1,5 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# matrix-react-sdk playwright & cypress tests (with access to repo secrets)
|
||||
# matrix-react-sdk playwright tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk End to End Tests
|
||||
on:
|
||||
@@ -13,28 +13,6 @@ 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 multiple concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.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
|
||||
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_RUST}}
|
||||
KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_CYPRESS_LEGACY}}
|
||||
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
|
||||
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
playwright:
|
||||
name: Playwright
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
@@ -49,15 +27,17 @@ jobs:
|
||||
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'
|
||||
@@ -65,18 +45,6 @@ jobs:
|
||||
statuses: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: success
|
||||
description: Cypress skipped
|
||||
|
||||
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
|
||||
# in matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml.
|
||||
context: "${{ github.workflow }} / cypress"
|
||||
|
||||
sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -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,10 +19,12 @@ 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.
|
||||
# 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 }}
|
||||
@@ -25,22 +33,55 @@ 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@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
if: always()
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.3.0-rc.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.4.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.54.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",
|
||||
|
||||
@@ -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,
|
||||
@@ -340,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,
|
||||
@@ -398,17 +398,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
|
||||
});
|
||||
|
||||
it("CryptoAPI.getOwnedDeviceKeys returns the correct values", async () => {
|
||||
const homeserverUrl = aliceClient.getHomeserverUrl();
|
||||
|
||||
keyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
await startClientAndAwaitFirstSync();
|
||||
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
|
||||
|
||||
it("CryptoAPI.getOwnDeviceKeys returns plausible values", async () => {
|
||||
const deviceKeys = await aliceClient.getCrypto()!.getOwnDeviceKeys();
|
||||
|
||||
expect(deviceKeys.curve25519).toEqual(keyReceiver.getDeviceKey());
|
||||
expect(deviceKeys.ed25519).toEqual(keyReceiver.getSigningKey());
|
||||
// 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 () => {
|
||||
@@ -2567,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(
|
||||
@@ -2592,6 +2610,14 @@ 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
|
||||
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
|
||||
|
||||
|
||||
@@ -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, IEvent, 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,7 +41,7 @@ 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";
|
||||
@@ -286,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]: {
|
||||
@@ -340,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,
|
||||
@@ -371,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]: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -1263,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();
|
||||
@@ -1289,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -3315,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}`);
|
||||
}
|
||||
@@ -3328,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;
|
||||
@@ -3348,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;
|
||||
@@ -3366,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);
|
||||
|
||||
@@ -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,7 +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";
|
||||
@@ -66,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", () => {
|
||||
@@ -158,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);
|
||||
@@ -294,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),
|
||||
@@ -421,7 +660,7 @@ describe("RustCrypto", () => {
|
||||
decryptEvent: () =>
|
||||
({
|
||||
senderCurve25519Key: "1234",
|
||||
} as IEventDecryptionResult),
|
||||
}) as IEventDecryptionResult,
|
||||
} as unknown as CryptoBackend;
|
||||
await event.attemptDecryption(mockCryptoBackend);
|
||||
|
||||
@@ -461,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;
|
||||
@@ -739,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 () => {
|
||||
@@ -760,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: {
|
||||
@@ -774,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]);
|
||||
@@ -900,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
|
||||
@@ -918,7 +1146,7 @@ describe("RustCrypto", () => {
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
mockHttpApi,
|
||||
makeMatrixHttpApi(),
|
||||
testData.TEST_USER_ID,
|
||||
testData.TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
@@ -977,6 +1205,15 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 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()
|
||||
@@ -988,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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+176
-49
@@ -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;
|
||||
|
||||
@@ -961,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;
|
||||
|
||||
@@ -2315,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;
|
||||
@@ -3895,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);
|
||||
|
||||
@@ -3931,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 {
|
||||
@@ -3958,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);
|
||||
}
|
||||
@@ -3967,15 +4019,88 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
backupDecryptor.free();
|
||||
}
|
||||
|
||||
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
});
|
||||
|
||||
/// 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>;
|
||||
@@ -5968,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 },
|
||||
);
|
||||
@@ -5983,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);
|
||||
}
|
||||
@@ -6361,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());
|
||||
|
||||
@@ -6388,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,
|
||||
@@ -6396,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()) {
|
||||
@@ -7444,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;
|
||||
@@ -7591,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<{
|
||||
@@ -7726,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8114,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> {
|
||||
|
||||
+11
-6
@@ -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";
|
||||
@@ -586,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -710,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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+45
-32
@@ -259,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 = {
|
||||
@@ -368,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 {
|
||||
@@ -708,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();
|
||||
}
|
||||
@@ -968,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,
|
||||
@@ -986,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;
|
||||
@@ -1060,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");
|
||||
@@ -1071,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: ???
|
||||
@@ -1083,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]);
|
||||
@@ -2244,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1003,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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -196,7 +196,11 @@ export class RoomReceipts {
|
||||
* maps in RoomReceipts.
|
||||
*/
|
||||
class ReceiptInfo {
|
||||
public constructor(public eventId: string, public receiptType: string, public ts: number) {}
|
||||
public constructor(
|
||||
public eventId: string,
|
||||
public receiptType: string,
|
||||
public ts: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
+8
-1
@@ -1979,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;
|
||||
}
|
||||
@@ -2082,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);
|
||||
}
|
||||
@@ -2329,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);
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
+66
-53
@@ -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!) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
import { logDuration, QueryDict } from "../utils";
|
||||
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
@@ -109,7 +109,9 @@ export class OutgoingRequestProcessor {
|
||||
|
||||
if (msg.id) {
|
||||
try {
|
||||
await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
|
||||
await logDuration(logger, `Mark Request as sent ${msg.type}`, async () => {
|
||||
await this.olmMachine.markRequestAsSent(msg.id!, msg.type, resp);
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors which are caused by the olmMachine having been freed. The exact error message depends
|
||||
// on whether we are using a release or develop build of rust-sdk-crypto-wasm.
|
||||
@@ -122,6 +124,8 @@ export class OutgoingRequestProcessor {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.trace(`Outgoing request type:${msg.type} does not have an ID`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { Logger } from "../logger";
|
||||
import { defer, IDeferred } from "../utils";
|
||||
import { defer, IDeferred, logDuration } from "../utils";
|
||||
|
||||
/**
|
||||
* OutgoingRequestsManager: responsible for processing outgoing requests from the OlmMachine.
|
||||
@@ -130,7 +130,9 @@ export class OutgoingRequestsManager {
|
||||
for (const request of outgoingRequests) {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
await logDuration(this.logger, `Make outgoing request ${request.type}`, async () => {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
});
|
||||
} catch (e) {
|
||||
// as part of the loop we silently ignore errors, but log them.
|
||||
// The rust sdk will retry the request later as it won't have been marked as sent.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { HistoryVisibility } from "../@types/partials";
|
||||
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
||||
import { logDuration } from "../utils";
|
||||
|
||||
/**
|
||||
* RoomEncryptor: responsible for encrypting messages to a given room
|
||||
@@ -45,6 +46,13 @@ export class RoomEncryptor {
|
||||
/** whether the room members have been loaded and tracked for the first time */
|
||||
private lazyLoadedMembersResolved = false;
|
||||
|
||||
/**
|
||||
* Ensures that there is only one encryption operation at a time for that room.
|
||||
*
|
||||
* An encryption operation is either a {@link prepareForEncryption} or an {@link encryptEvent} call.
|
||||
*/
|
||||
private currentEncryptionPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* @param olmMachine - The rust-sdk's OlmMachine
|
||||
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
|
||||
@@ -95,8 +103,10 @@ export class RoomEncryptor {
|
||||
(member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
|
||||
) {
|
||||
// make sure we are tracking the deviceList for this user
|
||||
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]).catch((e) => {
|
||||
this.prefixedLogger.error("Unable to update tracked users", e);
|
||||
logDuration(this.prefixedLogger, "updateTrackedUsers", async () => {
|
||||
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]).catch((e) => {
|
||||
this.prefixedLogger.error("Unable to update tracked users", e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,8 +122,47 @@ export class RoomEncryptor {
|
||||
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
|
||||
*/
|
||||
public async prepareForEncryption(globalBlacklistUnverifiedDevices: boolean): Promise<void> {
|
||||
const logger = new LogSpan(this.prefixedLogger, "prepareForEncryption");
|
||||
await this.ensureEncryptionSession(logger, globalBlacklistUnverifiedDevices);
|
||||
// We consider a prepareForEncryption as an encryption promise as it will potentially share keys
|
||||
// even if it doesn't send an event.
|
||||
// Usually this is called when the user starts typing, so we want to make sure we have keys ready when the
|
||||
// message is finally sent.
|
||||
// If `encryptEvent` is invoked before `prepareForEncryption` has completed, the `encryptEvent` call will wait for
|
||||
// `prepareForEncryption` to complete before executing.
|
||||
// The part where `encryptEvent` shares the room key will then usually be a no-op as it was already performed by `prepareForEncryption`.
|
||||
await this.encryptEvent(null, globalBlacklistUnverifiedDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an event for this room, or prepare for encryption.
|
||||
*
|
||||
* This will ensure that we have a megolm session for this room, share it with the devices in the room, and
|
||||
* then, if an event is provided, encrypt it using the session.
|
||||
*
|
||||
* @param event - Event to be encrypted, or null if only preparing for encryption (in which case we will pre-share the room key).
|
||||
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
|
||||
*/
|
||||
public encryptEvent(event: MatrixEvent | null, globalBlacklistUnverifiedDevices: boolean): Promise<void> {
|
||||
const logger = new LogSpan(this.prefixedLogger, event ? event.getTxnId() ?? "" : "prepareForEncryption");
|
||||
// Ensure order of encryption to avoid message ordering issues, as the scheduler only ensures
|
||||
// events order after they have been encrypted.
|
||||
const prom = this.currentEncryptionPromise
|
||||
.catch(() => {
|
||||
// Any errors in the previous call will have been reported already, so there is nothing to do here.
|
||||
// we just throw away the error and start anew.
|
||||
})
|
||||
.then(async () => {
|
||||
await logDuration(logger, "ensureEncryptionSession", async () => {
|
||||
await this.ensureEncryptionSession(logger, globalBlacklistUnverifiedDevices);
|
||||
});
|
||||
if (event) {
|
||||
await logDuration(logger, "encryptEventInner", async () => {
|
||||
await this.encryptEventInner(logger, event);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.currentEncryptionPromise = prom;
|
||||
return prom;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +191,9 @@ export class RoomEncryptor {
|
||||
// This could end up being racy (if two calls to ensureEncryptionSession happen at the same time), but that's
|
||||
// not a particular problem, since `OlmMachine.updateTrackedUsers` just adds any users that weren't already tracked.
|
||||
if (!this.lazyLoadedMembersResolved) {
|
||||
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
|
||||
await logDuration(this.prefixedLogger, "loadMembersIfNeeded: updateTrackedUsers", async () => {
|
||||
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
|
||||
});
|
||||
logger.debug(`Updated tracked users`);
|
||||
this.lazyLoadedMembersResolved = true;
|
||||
|
||||
@@ -156,7 +207,10 @@ export class RoomEncryptor {
|
||||
// at the end of the sync, but we can't wait for that).
|
||||
// XXX future improvement process only KeysQueryRequests for the users that have never been queried.
|
||||
logger.debug(`Processing outgoing requests`);
|
||||
await this.outgoingRequestManager.doProcessOutgoingRequests();
|
||||
|
||||
await logDuration(this.prefixedLogger, "doProcessOutgoingRequests", async () => {
|
||||
await this.outgoingRequestManager.doProcessOutgoingRequests();
|
||||
});
|
||||
} else {
|
||||
// If members are already loaded it's less critical to await on key queries.
|
||||
// We might still want to trigger a processOutgoingRequests here.
|
||||
@@ -174,7 +228,10 @@ export class RoomEncryptor {
|
||||
);
|
||||
|
||||
const userList = members.map((u) => new UserId(u.userId));
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, userList);
|
||||
|
||||
await logDuration(this.prefixedLogger, "ensureSessionsForUsers", async () => {
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, userList);
|
||||
});
|
||||
|
||||
const rustEncryptionSettings = new EncryptionSettings();
|
||||
rustEncryptionSettings.historyVisibility = toRustHistoryVisibility(this.room.getHistoryVisibility());
|
||||
@@ -198,16 +255,19 @@ export class RoomEncryptor {
|
||||
rustEncryptionSettings.onlyAllowTrustedDevices =
|
||||
this.room.getBlacklistUnverifiedDevices() ?? globalBlacklistUnverifiedDevices;
|
||||
|
||||
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
|
||||
new RoomId(this.room.roomId),
|
||||
userList,
|
||||
rustEncryptionSettings,
|
||||
);
|
||||
if (shareMessages) {
|
||||
for (const m of shareMessages) {
|
||||
await this.outgoingRequestManager.outgoingRequestProcessor.makeOutgoingRequest(m);
|
||||
await logDuration(this.prefixedLogger, "shareRoomKey", async () => {
|
||||
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
|
||||
new RoomId(this.room.roomId),
|
||||
// safe to pass without cloning, as it's not reused here (before or after)
|
||||
userList,
|
||||
rustEncryptionSettings,
|
||||
);
|
||||
if (shareMessages) {
|
||||
for (const m of shareMessages) {
|
||||
await this.outgoingRequestManager.outgoingRequestProcessor.makeOutgoingRequest(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,19 +280,7 @@ export class RoomEncryptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an event for this room
|
||||
*
|
||||
* This will ensure that we have a megolm session for this room, share it with the devices in the room, and
|
||||
* then encrypt the event using the session.
|
||||
*
|
||||
* @param event - Event to be encrypted.
|
||||
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
|
||||
*/
|
||||
public async encryptEvent(event: MatrixEvent, globalBlacklistUnverifiedDevices: boolean): Promise<void> {
|
||||
const logger = new LogSpan(this.prefixedLogger, event.getTxnId() ?? "");
|
||||
await this.ensureEncryptionSession(logger, globalBlacklistUnverifiedDevices);
|
||||
|
||||
private async encryptEventInner(logger: LogSpan, event: MatrixEvent): Promise<void> {
|
||||
logger.debug("Encrypting actual message content");
|
||||
const encryptedContent = await this.olmMachine.encryptRoomEvent(
|
||||
new RoomId(this.room.roomId),
|
||||
|
||||
+91
-33
@@ -29,12 +29,14 @@ import { logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { CryptoEvent, IMegolmSessionData } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { encodeUri, immediate } from "../utils";
|
||||
import { encodeUri, logDuration } from "../utils";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { sleep } from "../utils";
|
||||
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
|
||||
import { IEncryptedPayload } from "../crypto/aes";
|
||||
import { ImportRoomKeyProgressData, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { IKeyBackupInfo } from "../crypto/keybackup";
|
||||
import { IKeyBackup } from "../crypto/backup";
|
||||
|
||||
/** Authentification of the backup info, depends on algorithm */
|
||||
type AuthData = KeyBackupInfo["auth_data"];
|
||||
@@ -127,7 +129,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
/**
|
||||
* Handles a backup secret received event and store it if it matches the current backup version.
|
||||
*
|
||||
* @param secret - The secret as received from a `m.secert.send` event for secret `m.megolm_backup.v1`.
|
||||
* @param secret - The secret as received from a `m.secret.send` event for secret `m.megolm_backup.v1`.
|
||||
* @returns true if the secret is valid and has been stored, false otherwise.
|
||||
*/
|
||||
public async handleBackupSecretReceived(secret: string): Promise<boolean> {
|
||||
@@ -140,7 +142,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
if (!backupCheck?.backupInfo?.version || !backupCheck.trustInfo.trusted) {
|
||||
// There is no server-side key backup, or the backup is not signed by a trusted cross-signing key or trusted own device.
|
||||
// This decryption key is useless to us.
|
||||
logger.warn("Received backup decryption key, but there is no trusted server-side key backup");
|
||||
logger.warn(
|
||||
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -148,7 +152,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
|
||||
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(backupCheck.backupInfo, backupDecryptionKey);
|
||||
if (!privateKeyMatches) {
|
||||
logger.debug(`onReceiveSecret: backup decryption key does not match current backup version`);
|
||||
logger.warn(
|
||||
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
|
||||
);
|
||||
// just ignore the secret
|
||||
return false;
|
||||
}
|
||||
@@ -322,19 +328,35 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
await sleep(delay);
|
||||
|
||||
try {
|
||||
let numFailures = 0; // number of consecutive network failures for exponential backoff
|
||||
// number of consecutive network failures for exponential backoff
|
||||
let numFailures = 0;
|
||||
// The number of keys left to back up. (Populated lazily: see more comments below.)
|
||||
let remainingToUploadCount: number | null = null;
|
||||
// To avoid computing the key when only a few keys were added (after a sync for example),
|
||||
// we compute the count only when at least two iterations are needed.
|
||||
let isFirstIteration = true;
|
||||
|
||||
while (!this.stopped) {
|
||||
// Get a batch of room keys to upload
|
||||
let request: RustSdkCryptoJs.KeysBackupRequest | null = null;
|
||||
try {
|
||||
request = await this.olmMachine.backupRoomKeys();
|
||||
request = await logDuration(
|
||||
logger,
|
||||
"BackupRoomKeys: Get keys to backup from rust crypto-sdk",
|
||||
async () => {
|
||||
return await this.olmMachine.backupRoomKeys();
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Backup: Failed to get keys to backup from rust crypto-sdk", err);
|
||||
}
|
||||
|
||||
if (!request || this.stopped || !this.activeBackupVersion) {
|
||||
logger.log(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
|
||||
if (!request) {
|
||||
// nothing more to upload
|
||||
this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,12 +364,34 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
numFailures = 0;
|
||||
if (this.stopped) break;
|
||||
try {
|
||||
const keyCount = await this.olmMachine.roomKeyCounts();
|
||||
const remaining = keyCount.total - keyCount.backedUp;
|
||||
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
|
||||
} catch (err) {
|
||||
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
|
||||
|
||||
// Key count performance (`olmMachine.roomKeyCounts()`) can be pretty bad on some configurations.
|
||||
// In particular, we detected on some M1 macs that when the object store reaches a threshold, the count
|
||||
// performance stops growing in O(n) and suddenly becomes very slow (40s, 60s or more).
|
||||
// For reference, the performance drop occurs around 300-400k keys on the platforms where this issue is observed.
|
||||
// Even on other configurations, the count can take several seconds.
|
||||
// This will block other operations on the database, like sending messages.
|
||||
//
|
||||
// This is a workaround to avoid calling `olmMachine.roomKeyCounts()` too often, and only when necessary.
|
||||
// We don't call it on the first loop because there could be only a few keys to upload, and we don't want to wait for the count.
|
||||
if (!isFirstIteration && remainingToUploadCount === null) {
|
||||
try {
|
||||
const keyCount = await this.olmMachine.roomKeyCounts();
|
||||
remainingToUploadCount = keyCount.total - keyCount.backedUp;
|
||||
} catch (err) {
|
||||
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingToUploadCount !== null) {
|
||||
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remainingToUploadCount);
|
||||
const keysCountInBatch = this.keysCountInBatch(request);
|
||||
// `OlmMachine.roomKeyCounts` is called only once for the current backupKeysLoop. But new
|
||||
// keys could be added during the current loop (after a sync for example).
|
||||
// So the count can get out of sync with the real number of remaining keys to upload.
|
||||
// Depending on the number of new keys imported and the time to complete the loop,
|
||||
// this could result in multiple events being emitted with a remaining key count of 0.
|
||||
remainingToUploadCount = Math.max(remainingToUploadCount - keysCountInBatch, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
@@ -371,7 +415,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
// wait for that and then continue?
|
||||
const waitTime = err.data.retry_after_ms;
|
||||
if (waitTime > 0) {
|
||||
sleep(waitTime);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
} // else go to the normal backoff
|
||||
}
|
||||
@@ -381,35 +425,36 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
// exponential backoff if we have failures
|
||||
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
|
||||
}
|
||||
isFirstIteration = false;
|
||||
}
|
||||
} finally {
|
||||
this.backupKeysLoopRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to count the number of keys in a backup request, in order to update the remaining keys count.
|
||||
* This should be the chunk size of the backup request for all requests but the last, but we don't have access to it
|
||||
* (it's static in the Rust SDK).
|
||||
* @param batch - The backup request to count the keys from.
|
||||
*
|
||||
* @returns The number of keys in the backup request.
|
||||
*/
|
||||
private keysCountInBatch(batch: RustSdkCryptoJs.KeysBackupRequest): number {
|
||||
const parsedBody: IKeyBackup = JSON.parse(batch.body);
|
||||
let count = 0;
|
||||
for (const { sessions } of Object.values(parsedBody.rooms)) {
|
||||
count += Object.keys(sessions).length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
/**
|
||||
* Get information about the current key backup from the server
|
||||
*
|
||||
* @returns Information object from API or null if there is no active backup.
|
||||
*/
|
||||
public async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
|
||||
try {
|
||||
return await this.http.authedRequest<KeyBackupInfo>(
|
||||
Method.Get,
|
||||
"/room_keys/version",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: ClientPrefix.V3,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return await requestKeyBackupVersion(this.http);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -543,9 +588,6 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
);
|
||||
decrypted.session_id = sessionId;
|
||||
keys.push(decrypted);
|
||||
|
||||
// there might be lots of sessions, so don't hog the event loop
|
||||
await immediate();
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
}
|
||||
@@ -561,6 +603,22 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestKeyBackupVersion(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
): Promise<IKeyBackupInfo | null> {
|
||||
try {
|
||||
return await http.authedRequest<KeyBackupInfo>(Method.Get, "/room_keys/version", undefined, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
} catch (e) {
|
||||
if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RustBackupCryptoEvents =
|
||||
| CryptoEvent.KeyBackupStatus
|
||||
| CryptoEvent.KeyBackupSessionsRemaining
|
||||
|
||||
+100
-32
@@ -15,57 +15,126 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { StoreHandle } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { ICryptoCallbacks } from "../crypto";
|
||||
import { Logger } from "../logger";
|
||||
import { CryptoStore } from "../crypto/store/base";
|
||||
import { migrateFromLegacyCrypto } from "./libolm_migration";
|
||||
|
||||
/**
|
||||
* Create a new `RustCrypto` implementation
|
||||
*
|
||||
* @param logger - A `Logger` instance that will be used for debug output.
|
||||
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
* @param userId - The local user's User ID.
|
||||
* @param deviceId - The local user's Device ID.
|
||||
* @param secretStorage - Interface to server-side secret storage.
|
||||
* @param cryptoCallbacks - Crypto callbacks provided by the application
|
||||
* @param storePrefix - the prefix to use on the indexeddbs created by rust-crypto.
|
||||
* If `null`, a memory store will be used.
|
||||
* @param storePassphrase - a passphrase to use to encrypt the indexeddbs created by rust-crypto.
|
||||
* Ignored if `storePrefix` is null. If this is `undefined` (and `storePrefix` is not null), the indexeddbs
|
||||
* will be unencrypted.
|
||||
*
|
||||
* @param args - Parameter object
|
||||
* @internal
|
||||
*/
|
||||
export async function initRustCrypto(
|
||||
export async function initRustCrypto(args: {
|
||||
/** A `Logger` instance that will be used for debug output. */
|
||||
logger: Logger;
|
||||
|
||||
/**
|
||||
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
*/
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>;
|
||||
|
||||
/** The local user's User ID. */
|
||||
userId: string;
|
||||
|
||||
/** The local user's Device ID. */
|
||||
deviceId: string;
|
||||
|
||||
/** Interface to server-side secret storage. */
|
||||
secretStorage: ServerSideSecretStorage;
|
||||
|
||||
/** Crypto callbacks provided by the application. */
|
||||
cryptoCallbacks: ICryptoCallbacks;
|
||||
|
||||
/**
|
||||
* The prefix to use on the indexeddbs created by rust-crypto.
|
||||
* If `null`, a memory store will be used.
|
||||
*/
|
||||
storePrefix: string | null;
|
||||
|
||||
/**
|
||||
* A passphrase to use to encrypt the indexeddbs created by rust-crypto.
|
||||
*
|
||||
* Ignored if `storePrefix` is null. If this is `undefined` (and `storePrefix` is not null), the indexeddbs
|
||||
* will be unencrypted.
|
||||
*/
|
||||
storePassphrase?: string;
|
||||
|
||||
/** If defined, we will check if any data needs migrating from this store to the rust store. */
|
||||
legacyCryptoStore?: CryptoStore;
|
||||
|
||||
/** The pickle key for `legacyCryptoStore` */
|
||||
legacyPickleKey?: string;
|
||||
|
||||
/**
|
||||
* A callback which will receive progress updates on migration from `legacyCryptoStore`.
|
||||
*
|
||||
* Called with (-1, -1) to mark the end of migration.
|
||||
*/
|
||||
legacyMigrationProgressListener?: (progress: number, total: number) => void;
|
||||
}): Promise<RustCrypto> {
|
||||
const { logger } = args;
|
||||
|
||||
// initialise the rust matrix-sdk-crypto-wasm, if it hasn't already been done
|
||||
logger.debug("Initialising Rust crypto-sdk WASM artifact");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
logger.debug("Opening Rust CryptoStore");
|
||||
const storeHandle: StoreHandle = await StoreHandle.open(
|
||||
args.storePrefix ?? undefined,
|
||||
(args.storePrefix && args.storePassphrase) ?? undefined,
|
||||
);
|
||||
|
||||
if (args.legacyCryptoStore) {
|
||||
// We have a legacy crypto store, which we may need to migrate from.
|
||||
await migrateFromLegacyCrypto({
|
||||
legacyStore: args.legacyCryptoStore,
|
||||
storeHandle,
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
const rustCrypto = await initOlmMachine(
|
||||
logger,
|
||||
args.http,
|
||||
args.userId,
|
||||
args.deviceId,
|
||||
args.secretStorage,
|
||||
args.cryptoCallbacks,
|
||||
storeHandle,
|
||||
);
|
||||
|
||||
storeHandle.free();
|
||||
|
||||
logger.debug("Completed rust crypto-sdk setup");
|
||||
return rustCrypto;
|
||||
}
|
||||
|
||||
async function initOlmMachine(
|
||||
logger: Logger,
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
cryptoCallbacks: ICryptoCallbacks,
|
||||
storePrefix: string | null,
|
||||
storePassphrase: string | undefined,
|
||||
storeHandle: StoreHandle,
|
||||
): Promise<RustCrypto> {
|
||||
// initialise the rust matrix-sdk-crypto-wasm, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
logger.debug("Init OlmMachine");
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
const u = new RustSdkCryptoJs.UserId(userId);
|
||||
const d = new RustSdkCryptoJs.DeviceId(deviceId);
|
||||
logger.info("Init OlmMachine");
|
||||
|
||||
// TODO: use the pickle key for the passphrase
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(
|
||||
u,
|
||||
d,
|
||||
storePrefix ?? undefined,
|
||||
(storePrefix && storePassphrase) ?? undefined,
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initFromStore(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.DeviceId(deviceId),
|
||||
storeHandle,
|
||||
);
|
||||
|
||||
// Disable room key requests, per https://github.com/vector-im/element-web/issues/26524.
|
||||
@@ -101,6 +170,5 @@ export async function initRustCrypto(
|
||||
// XXX: find a less hacky way to do this.
|
||||
await olmMachine.outgoingRequests();
|
||||
|
||||
logger.info("Completed rust crypto-sdk setup");
|
||||
return rustCrypto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { Logger } from "../logger";
|
||||
import { CryptoStore, MigrationState, SecretStorePrivateKeys } from "../crypto/store/base";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, IEncryptedPayload } from "../crypto/aes";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { requestKeyBackupVersion } from "./backup";
|
||||
|
||||
/**
|
||||
* Determine if any data needs migrating from the legacy store, and do so.
|
||||
*
|
||||
* @param args - Arguments object.
|
||||
*/
|
||||
export async function migrateFromLegacyCrypto(args: {
|
||||
/** A `Logger` instance that will be used for debug output. */
|
||||
logger: Logger;
|
||||
|
||||
/**
|
||||
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
*/
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>;
|
||||
|
||||
/** Store to migrate data from. */
|
||||
legacyStore: CryptoStore;
|
||||
|
||||
/** Pickle key for `legacyStore`. */
|
||||
legacyPickleKey?: string;
|
||||
|
||||
/** Local user's User ID. */
|
||||
userId: string;
|
||||
|
||||
/** Local user's Device ID. */
|
||||
deviceId: string;
|
||||
|
||||
/** Rust crypto store to migrate data into. */
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle;
|
||||
|
||||
/**
|
||||
* A callback which will receive progress updates on migration from `legacyStore`.
|
||||
*
|
||||
* Called with (-1, -1) to mark the end of migration.
|
||||
*/
|
||||
legacyMigrationProgressListener?: (progress: number, total: number) => void;
|
||||
}): Promise<void> {
|
||||
const { logger, legacyStore } = args;
|
||||
|
||||
// initialise the rust matrix-sdk-crypto-wasm, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
if (!(await legacyStore.containsData())) {
|
||||
// This store was never used. Nothing to migrate.
|
||||
return;
|
||||
}
|
||||
|
||||
await legacyStore.startup();
|
||||
let migrationState = await legacyStore.getMigrationState();
|
||||
|
||||
if (migrationState === MigrationState.MEGOLM_SESSIONS_MIGRATED) {
|
||||
// All migration is done.
|
||||
return;
|
||||
}
|
||||
|
||||
const nOlmSessions = await countOlmSessions(logger, legacyStore);
|
||||
const nMegolmSessions = await countMegolmSessions(logger, legacyStore);
|
||||
const totalSteps = 1 + nOlmSessions + nMegolmSessions;
|
||||
logger.info(
|
||||
`Migrating data from legacy crypto store. ${nOlmSessions} olm sessions and ${nMegolmSessions} megolm sessions to migrate.`,
|
||||
);
|
||||
|
||||
let stepsDone = 0;
|
||||
function onProgress(steps: number): void {
|
||||
stepsDone += steps;
|
||||
args.legacyMigrationProgressListener?.(stepsDone, totalSteps);
|
||||
}
|
||||
onProgress(0);
|
||||
|
||||
const pickleKey = new TextEncoder().encode(args.legacyPickleKey);
|
||||
|
||||
if (migrationState === MigrationState.NOT_STARTED) {
|
||||
logger.info("Migrating data from legacy crypto store. Step 1: base data");
|
||||
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle);
|
||||
|
||||
migrationState = MigrationState.INITIAL_DATA_MIGRATED;
|
||||
await legacyStore.setMigrationState(migrationState);
|
||||
}
|
||||
onProgress(1);
|
||||
|
||||
if (migrationState === MigrationState.INITIAL_DATA_MIGRATED) {
|
||||
logger.info(
|
||||
`Migrating data from legacy crypto store. Step 2: olm sessions (${nOlmSessions} sessions to migrate).`,
|
||||
);
|
||||
await migrateOlmSessions(logger, legacyStore, pickleKey, args.storeHandle, onProgress);
|
||||
|
||||
migrationState = MigrationState.OLM_SESSIONS_MIGRATED;
|
||||
await legacyStore.setMigrationState(migrationState);
|
||||
}
|
||||
|
||||
if (migrationState === MigrationState.OLM_SESSIONS_MIGRATED) {
|
||||
logger.info(
|
||||
`Migrating data from legacy crypto store. Step 3: megolm sessions (${nMegolmSessions} sessions to migrate).`,
|
||||
);
|
||||
await migrateMegolmSessions(logger, legacyStore, pickleKey, args.storeHandle, onProgress);
|
||||
|
||||
migrationState = MigrationState.MEGOLM_SESSIONS_MIGRATED;
|
||||
await legacyStore.setMigrationState(migrationState);
|
||||
}
|
||||
|
||||
// Migration is done.
|
||||
args.legacyMigrationProgressListener?.(-1, -1);
|
||||
logger.info("Migration from legacy crypto store complete");
|
||||
}
|
||||
|
||||
async function migrateBaseData(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
legacyStore: CryptoStore,
|
||||
pickleKey: Uint8Array,
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle,
|
||||
): Promise<void> {
|
||||
const migrationData = new RustSdkCryptoJs.BaseMigrationData();
|
||||
migrationData.userId = new RustSdkCryptoJs.UserId(userId);
|
||||
migrationData.deviceId = new RustSdkCryptoJs.DeviceId(deviceId);
|
||||
|
||||
await legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) =>
|
||||
legacyStore.getAccount(txn, (a) => {
|
||||
migrationData.pickledAccount = a ?? "";
|
||||
}),
|
||||
);
|
||||
|
||||
const recoveryKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "m.megolm_backup.v1");
|
||||
|
||||
// If we have a backup recovery key, we need to try to figure out which backup version it is for.
|
||||
// All we can really do is ask the server for the most recent version.
|
||||
if (recoveryKey) {
|
||||
const backupInfo = await requestKeyBackupVersion(http);
|
||||
if (backupInfo) {
|
||||
migrationData.backupVersion = backupInfo.version;
|
||||
migrationData.backupRecoveryKey = recoveryKey;
|
||||
}
|
||||
}
|
||||
|
||||
migrationData.privateCrossSigningMasterKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "master");
|
||||
migrationData.privateCrossSigningSelfSigningKey = await getAndDecryptCachedSecretKey(
|
||||
legacyStore,
|
||||
pickleKey,
|
||||
"self_signing",
|
||||
);
|
||||
migrationData.privateCrossSigningUserSigningKey = await getAndDecryptCachedSecretKey(
|
||||
legacyStore,
|
||||
pickleKey,
|
||||
"user_signing",
|
||||
);
|
||||
await RustSdkCryptoJs.Migration.migrateBaseData(migrationData, pickleKey, storeHandle);
|
||||
}
|
||||
|
||||
async function countOlmSessions(logger: Logger, legacyStore: CryptoStore): Promise<number> {
|
||||
logger.debug("Counting olm sessions to be migrated");
|
||||
let nSessions: number;
|
||||
await legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
|
||||
legacyStore.countEndToEndSessions(txn, (n) => (nSessions = n)),
|
||||
);
|
||||
return nSessions!;
|
||||
}
|
||||
|
||||
async function countMegolmSessions(logger: Logger, legacyStore: CryptoStore): Promise<number> {
|
||||
logger.debug("Counting megolm sessions to be migrated");
|
||||
return await legacyStore.countEndToEndInboundGroupSessions();
|
||||
}
|
||||
|
||||
async function migrateOlmSessions(
|
||||
logger: Logger,
|
||||
legacyStore: CryptoStore,
|
||||
pickleKey: Uint8Array,
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle,
|
||||
onBatchDone: (batchSize: number) => void,
|
||||
): Promise<void> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const batch = await legacyStore.getEndToEndSessionsBatch();
|
||||
if (batch === null) return;
|
||||
|
||||
logger.debug(`Migrating batch of ${batch.length} olm sessions`);
|
||||
const migrationData: RustSdkCryptoJs.PickledSession[] = [];
|
||||
for (const session of batch) {
|
||||
const pickledSession = new RustSdkCryptoJs.PickledSession();
|
||||
pickledSession.senderKey = session.deviceKey!;
|
||||
pickledSession.pickle = session.session!;
|
||||
pickledSession.lastUseTime = pickledSession.creationTime = new Date(session.lastReceivedMessageTs!);
|
||||
migrationData.push(pickledSession);
|
||||
}
|
||||
|
||||
await RustSdkCryptoJs.Migration.migrateOlmSessions(migrationData, pickleKey, storeHandle);
|
||||
await legacyStore.deleteEndToEndSessionsBatch(batch);
|
||||
onBatchDone(batch.length);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateMegolmSessions(
|
||||
logger: Logger,
|
||||
legacyStore: CryptoStore,
|
||||
pickleKey: Uint8Array,
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle,
|
||||
onBatchDone: (batchSize: number) => void,
|
||||
): Promise<void> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const batch = await legacyStore.getEndToEndInboundGroupSessionsBatch();
|
||||
if (batch === null) return;
|
||||
|
||||
logger.debug(`Migrating batch of ${batch.length} megolm sessions`);
|
||||
const migrationData: RustSdkCryptoJs.PickledInboundGroupSession[] = [];
|
||||
for (const session of batch) {
|
||||
const pickledSession = new RustSdkCryptoJs.PickledInboundGroupSession();
|
||||
pickledSession.pickle = session.sessionData!.session;
|
||||
pickledSession.roomId = new RustSdkCryptoJs.RoomId(session.sessionData!.room_id);
|
||||
pickledSession.senderKey = session.senderKey;
|
||||
pickledSession.senderSigningKey = session.sessionData!.keysClaimed["ed25519"];
|
||||
pickledSession.backedUp = !session.needsBackup;
|
||||
|
||||
// Not sure if we can reliably distinguish imported vs not-imported sessions in the libolm database.
|
||||
// For now at least, let's be conservative and say that all the sessions are imported (which means that
|
||||
// the Rust SDK treats them as less secure).
|
||||
pickledSession.imported = true;
|
||||
|
||||
migrationData.push(pickledSession);
|
||||
}
|
||||
|
||||
await RustSdkCryptoJs.Migration.migrateMegolmSessions(migrationData, pickleKey, storeHandle);
|
||||
await legacyStore.deleteEndToEndInboundGroupSessionsBatch(batch);
|
||||
onBatchDone(batch.length);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAndDecryptCachedSecretKey(
|
||||
legacyStore: CryptoStore,
|
||||
legacyPickleKey: Uint8Array,
|
||||
name: string,
|
||||
): Promise<string | undefined> {
|
||||
let encodedKey: IEncryptedPayload | null = null;
|
||||
|
||||
await legacyStore.doTxn("readonly", "account", (txn) => {
|
||||
legacyStore.getSecretStorePrivateKey(
|
||||
txn,
|
||||
(k) => {
|
||||
encodedKey = k as IEncryptedPayload | null;
|
||||
},
|
||||
name as keyof SecretStorePrivateKeys,
|
||||
);
|
||||
});
|
||||
|
||||
return encodedKey === null ? undefined : await decryptAES(encodedKey, legacyPickleKey, name);
|
||||
}
|
||||
@@ -24,12 +24,12 @@ import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { Logger } from "../logger";
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { logDuration, MapWithDefault } from "../utils";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
|
||||
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage";
|
||||
import { SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity";
|
||||
import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage";
|
||||
import { keyFromPassphrase } from "../crypto/key_passphrase";
|
||||
@@ -315,18 +315,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
|
||||
*/
|
||||
public async getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
|
||||
const device: RustSdkCryptoJs.Device = await this.olmMachine.getDevice(
|
||||
this.olmMachine.userId,
|
||||
this.olmMachine.deviceId,
|
||||
);
|
||||
// could be undefined if there is no such algorithm for that device.
|
||||
if (device.curve25519Key && device.ed25519Key) {
|
||||
return {
|
||||
ed25519: device.ed25519Key.toBase64(),
|
||||
curve25519: device.curve25519Key.toBase64(),
|
||||
};
|
||||
}
|
||||
throw new Error("Device keys not found");
|
||||
const keys = this.olmMachine.identityKeys;
|
||||
return { ed25519: keys.ed25519.toBase64(), curve25519: keys.curve25519.toBase64() };
|
||||
}
|
||||
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
@@ -369,7 +359,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
/* make sure we have an *up-to-date* idea of the user's cross-signing keys. This is important, because if we
|
||||
* return "false" here, we will end up generating new cross-signing keys and replacing the existing ones.
|
||||
*/
|
||||
const request = this.olmMachine.queryKeysForUsers([rustTrackedUser]);
|
||||
const request = this.olmMachine.queryKeysForUsers(
|
||||
// clone as rust layer will take ownership and it's reused later
|
||||
[rustTrackedUser.clone()],
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
}
|
||||
const userIdentity = await this.olmMachine.getIdentity(rustTrackedUser);
|
||||
@@ -732,10 +725,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
await this.secretStorage.store("m.cross_signing.master", crossSigningPrivateKeys.masterKey);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", crossSigningPrivateKeys.userSigningKey);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", crossSigningPrivateKeys.self_signing_key);
|
||||
}
|
||||
|
||||
if (setupNewKeyBackup) {
|
||||
await this.resetKeyBackup();
|
||||
}
|
||||
if (setupNewKeyBackup) {
|
||||
await this.resetKeyBackup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,15 +741,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* @param secretStorageKey - The secret storage key to add in the secret storage.
|
||||
*/
|
||||
private async addSecretStorageKeyToSecretStorage(secretStorageKey: GeneratedSecretStorageKey): Promise<void> {
|
||||
// keyInfo is required to continue
|
||||
if (!secretStorageKey.keyInfo) {
|
||||
throw new Error("missing keyInfo field in the secret storage key");
|
||||
}
|
||||
|
||||
const secretStorageKeyObject = await this.secretStorage.addKey(
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
secretStorageKey.keyInfo,
|
||||
);
|
||||
const secretStorageKeyObject = await this.secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, {
|
||||
passphrase: secretStorageKey.keyInfo?.passphrase,
|
||||
name: secretStorageKey.keyInfo?.name,
|
||||
key: secretStorageKey.privateKey,
|
||||
});
|
||||
|
||||
await this.secretStorage.setDefaultKeyId(secretStorageKeyObject.keyId);
|
||||
|
||||
@@ -817,30 +806,29 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Implementation of {@link CryptoApi#createRecoveryKeyFromPassphrase}
|
||||
*/
|
||||
public async createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey> {
|
||||
let key: Uint8Array;
|
||||
|
||||
const keyInfo: AddSecretStorageKeyOpts = {};
|
||||
if (password) {
|
||||
// Generate the key from the passphrase
|
||||
const derivation = await keyFromPassphrase(password);
|
||||
keyInfo.passphrase = {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: derivation.iterations,
|
||||
salt: derivation.salt,
|
||||
return {
|
||||
keyInfo: {
|
||||
passphrase: {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: derivation.iterations,
|
||||
salt: derivation.salt,
|
||||
},
|
||||
},
|
||||
privateKey: derivation.key,
|
||||
encodedPrivateKey: encodeRecoveryKey(derivation.key),
|
||||
};
|
||||
key = derivation.key;
|
||||
} else {
|
||||
// Using the navigator crypto API to generate the private key
|
||||
key = new Uint8Array(32);
|
||||
const key = new Uint8Array(32);
|
||||
crypto.getRandomValues(key);
|
||||
return {
|
||||
privateKey: key,
|
||||
encodedPrivateKey: encodeRecoveryKey(key),
|
||||
};
|
||||
}
|
||||
|
||||
const encodedPrivateKey = encodeRecoveryKey(key);
|
||||
return {
|
||||
keyInfo,
|
||||
encodedPrivateKey,
|
||||
privateKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1225,12 +1213,14 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
unusedFallbackKeys?: Set<string>;
|
||||
devices?: RustSdkCryptoJs.DeviceLists;
|
||||
}): Promise<IToDeviceEvent[]> {
|
||||
const result = await this.olmMachine.receiveSyncChanges(
|
||||
events ? JSON.stringify(events) : "[]",
|
||||
devices,
|
||||
oneTimeKeysCounts,
|
||||
unusedFallbackKeys,
|
||||
);
|
||||
const result = await logDuration(logger, "receiveSyncChanges", async () => {
|
||||
return await this.olmMachine.receiveSyncChanges(
|
||||
events ? JSON.stringify(events) : "[]",
|
||||
devices,
|
||||
oneTimeKeysCounts,
|
||||
unusedFallbackKeys,
|
||||
);
|
||||
});
|
||||
|
||||
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
|
||||
return JSON.parse(result);
|
||||
|
||||
@@ -15,9 +15,10 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Emoji, QrState } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { QrState } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import {
|
||||
GeneratedSas,
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
VerificationPhase,
|
||||
@@ -659,18 +660,23 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
|
||||
/** if we can now show the callbacks, do so */
|
||||
protected onChange(): void {
|
||||
if (this.callbacks === null) {
|
||||
const emoji: Array<Emoji> | undefined = this.inner.emoji();
|
||||
const decimal = this.inner.decimals() as [number, number, number] | undefined;
|
||||
const emoji = this.inner.emoji();
|
||||
const decimal = this.inner.decimals();
|
||||
|
||||
if (emoji === undefined && decimal === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sas: GeneratedSas = {};
|
||||
if (emoji) {
|
||||
sas.emoji = emoji.map((e) => [e.symbol, e.description]);
|
||||
}
|
||||
if (decimal) {
|
||||
sas.decimal = [decimal[0], decimal[1], decimal[2]];
|
||||
}
|
||||
|
||||
this.callbacks = {
|
||||
sas: {
|
||||
decimal: decimal,
|
||||
emoji: emoji?.map((e) => [e.symbol, e.description]),
|
||||
},
|
||||
sas,
|
||||
confirm: async (): Promise<void> => {
|
||||
const requests: Array<OutgoingRequest> = await this.inner.confirm();
|
||||
for (const m of requests) {
|
||||
|
||||
@@ -100,10 +100,12 @@ export interface PassphraseInfo {
|
||||
* Options for {@link ServerSideSecretStorageImpl#addKey}.
|
||||
*/
|
||||
export interface AddSecretStorageKeyOpts {
|
||||
pubkey?: string;
|
||||
/** Information for deriving the key from a passphrase if any. */
|
||||
passphrase?: PassphraseInfo;
|
||||
/** Optional name of the key. */
|
||||
name?: string;
|
||||
key?: Uint8Array;
|
||||
/** The private key. Will be used to generate the key check values in the key info; it will not be stored on the server */
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,7 +382,7 @@ export class ServerSideSecretStorageImpl implements ServerSideSecretStorage {
|
||||
*/
|
||||
public async addKey(
|
||||
algorithm: string,
|
||||
opts: AddSecretStorageKeyOpts = {},
|
||||
opts: AddSecretStorageKeyOpts,
|
||||
keyId?: string,
|
||||
): Promise<SecretStorageKeyObject> {
|
||||
if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
|
||||
@@ -396,11 +398,10 @@ export class ServerSideSecretStorageImpl implements ServerSideSecretStorage {
|
||||
if (opts.passphrase) {
|
||||
keyInfo.passphrase = opts.passphrase;
|
||||
}
|
||||
if (opts.key) {
|
||||
const { iv, mac } = await calculateKeyCheck(opts.key);
|
||||
keyInfo.iv = iv;
|
||||
keyInfo.mac = mac;
|
||||
}
|
||||
|
||||
const { iv, mac } = await calculateKeyCheck(opts.key);
|
||||
keyInfo.iv = iv;
|
||||
keyInfo.mac = mac;
|
||||
|
||||
// Create a unique key id. XXX: this is racey.
|
||||
if (!keyId) {
|
||||
|
||||
@@ -114,7 +114,10 @@ type ExtensionToDeviceResponse = {
|
||||
class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> {
|
||||
private nextBatch: string | null = null;
|
||||
|
||||
public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {}
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
private readonly cryptoCallbacks?: SyncCryptoCallbacks,
|
||||
) {}
|
||||
|
||||
public name(): string {
|
||||
return "to_device";
|
||||
|
||||
@@ -143,7 +143,10 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
* @param dbName - Optional database name. The same name must be used
|
||||
* to open the same database.
|
||||
*/
|
||||
public constructor(private readonly indexedDB: IDBFactory, dbName = "default") {
|
||||
public constructor(
|
||||
private readonly indexedDB: IDBFactory,
|
||||
dbName = "default",
|
||||
) {
|
||||
this.dbName = "matrix-js-sdk:" + dbName;
|
||||
this.syncAccumulator = new SyncAccumulator();
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
* @param dbName - Optional database name. The same name must be used
|
||||
* to open the same database.
|
||||
*/
|
||||
public constructor(private readonly workerFactory: () => Worker, private readonly dbName?: string) {}
|
||||
public constructor(
|
||||
private readonly workerFactory: () => Worker,
|
||||
private readonly dbName?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempt to connect to the database. This can fail if the user does not
|
||||
|
||||
+5
-1
@@ -234,7 +234,11 @@ export class SyncApi {
|
||||
* @param syncOpts - sync-specific options passed by the client
|
||||
* @internal
|
||||
*/
|
||||
public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) {
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
opts?: IStoredClientOpts,
|
||||
syncOpts?: SyncApiOptions,
|
||||
) {
|
||||
this.opts = defaultClientOpts(opts);
|
||||
this.syncOpts = defaultSyncApiOpts(syncOpts);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { THREAD_RELATION_TYPE } from "./models/thread";
|
||||
import { IEvent } from "./models/event";
|
||||
|
||||
/**
|
||||
* Returns a filter function for the /relations endpoint to filter out relations directly
|
||||
* to the thread root event that should not live in the thread timeline
|
||||
*
|
||||
* @param threadId - the thread ID (ie. the event ID of the root event of the thread)
|
||||
* @returns the filtered list of events
|
||||
*/
|
||||
export function getRelationsThreadFilter(threadId: string): (e: Partial<IEvent>) => boolean {
|
||||
return (e: Partial<IEvent>) =>
|
||||
e.content?.["m.relates_to"]?.event_id !== threadId ||
|
||||
e.content?.["m.relates_to"]?.rel_type === THREAD_RELATION_TYPE.name;
|
||||
}
|
||||
@@ -441,7 +441,10 @@ export class TimelineIndex {
|
||||
public pendingPaginate?: Promise<boolean>;
|
||||
|
||||
// index: the indexes are relative to BaseIndex, so could well be negative.
|
||||
public constructor(public timeline: EventTimeline, public index: number) {}
|
||||
public constructor(
|
||||
public timeline: EventTimeline,
|
||||
public index: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns the minimum possible value for the index in the current
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Optional } from "matrix-events-sdk";
|
||||
import { IEvent, MatrixEvent } from "./models/event";
|
||||
import { M_TIMESTAMP } from "./@types/location";
|
||||
import { ReceiptType } from "./@types/read_receipts";
|
||||
import { BaseLogger } from "./logger";
|
||||
|
||||
const interns = new Map<string, string>();
|
||||
|
||||
@@ -387,6 +388,23 @@ export function sleep<T>(ms: number, value?: T): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to log the duration of a promise.
|
||||
*
|
||||
* @param logger - The logger to log to.
|
||||
* @param name - The name of the operation.
|
||||
* @param block - The block to execute.
|
||||
*/
|
||||
export async function logDuration<T>(logger: BaseLogger, name: string, block: () => Promise<T>): Promise<T> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
return await block();
|
||||
} finally {
|
||||
const end = Date.now();
|
||||
logger.debug(`[Perf]: ${name} took ${end - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise/async version of {@link setImmediate}.
|
||||
*/
|
||||
|
||||
+18
-2
@@ -15,6 +15,22 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The minimum Matrix specification version the js-sdk supports.
|
||||
* A list of the spec versions which the js-sdk is compatible with.
|
||||
*
|
||||
* In practice, this means: when we connect to a server, it must declare support for one of the versions in this list.
|
||||
*
|
||||
* Note that it does not *necessarily* mean that the js-sdk has good support for all the features in the listed spec
|
||||
* versions; only that we should be able to provide a base level of functionality with a server that offers support for
|
||||
* any of the listed versions.
|
||||
*/
|
||||
export const MINIMUM_MATRIX_VERSION = "v1.1";
|
||||
export const SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9"];
|
||||
|
||||
/**
|
||||
* The oldest Matrix specification version the js-sdk supports.
|
||||
*/
|
||||
export const MINIMUM_MATRIX_VERSION = SUPPORTED_MATRIX_VERSIONS[0];
|
||||
|
||||
/**
|
||||
* The most recent Matrix specification version the js-sdk supports.
|
||||
*/
|
||||
export const MAXIMUM_MATRIX_VERSION = SUPPORTED_MATRIX_VERSIONS[SUPPORTED_MATRIX_VERSIONS.length - 1];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user