Compare commits

..

64 Commits

Author SHA1 Message Date
RiotRobot 89d0133c61 v31.2.0 2024-01-31 14:37:57 +00:00
RiotRobot 5973a15f68 v31.2.0-rc.0 2024-01-23 18:26:23 +00:00
Richard van der Hoff 3c28cfc96a Fix type error introduced by crypto-wasm 4.0.0 (#4026)
* Fix type error introduced by crypto-wasm 4.0.0

* fix imports
2024-01-23 12:52:53 +00:00
Valere c99378501b ElementR | backup: call expensive roomKeyCounts less often (#4015)
* ElementR | backup: call expensive `roomKeyCounts` less often

* review: Improve doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: Improve loop

* review: Add comment regarding slightly outdated remaining count

* Review: doc fix typo

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: refactor code order, count after doing the request

* review: Missing await on sleep for limit exceeded

* review: Comment | add a note for when performance drops

* Backup: add upload loop test for rust

* test: quick fix backup loop tests

* test: quick fix imports backup loop tests

* review: improve comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review improve comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review: Clean and improve tests

* fix: wrong test name

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-22 19:06:22 +00:00
Valere b10a804a03 Element R: Bump matrix-rust-sdk-crypto-wasm to version 4.0.0 (#4021)
* bump wasm bindings version 4.0.0

* fix test compilation with initFromStore

* Fix test due to change in wasm handling of Vec<>

* review: Better doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: Better doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: revert userIdentity free removal

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-22 15:17:53 +00:00
RiotRobot 2337d5a7af Merge branch 'master' into develop 2024-01-19 13:44:53 +00:00
RiotRobot 5a49ed4ebb v31.1.0 2024-01-19 13:44:03 +00:00
Valere 22db9eb245 logs: improve logging (#4018) 2024-01-19 12:22:41 +00:00
Valere 4cddc7397d Decrypt and Import full backups in chunk with progress (#4005)
* Decrypt and Import full backups in chunk with progress

* backup chunk decryption jsdoc

* Review: fix capitalization

* review: better var name

* review: fix better iterate on object

* review: extract utility function

* review: Improve test, ensure mock calls

* review: Add more test for decryption or import failures

* Review: fix typo

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2024-01-19 10:08:45 +00:00
Michael Telatynski 418b69914a Fix issues caused by the artifacts v4 upgrade
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:30:15 +00:00
Michael Telatynski 0082964345 Iterate sonarcloud reusable action
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:27:05 +00:00
Michael Telatynski 96b3c79566 Iterate sonarcloud reusable action
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:12:43 +00:00
Michael Telatynski 41a6f18125 Fix Sonarcloud artifact downloading
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:03:07 +00:00
Michael Telatynski a2b2e8dbdf Use Github Artifacts v4 (#4011) 2024-01-19 08:54:45 +00:00
ElementRobot abd920f0f4 [Backport staging] Broaden spec version support (#4016)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Fixes https://github.com/matrix-org/matrix-js-sdk/issues/3915.
2024-01-18 17:40:07 +00:00
Matthew Hodgson 5333d0e0ba drop 'malformed member event' log level, given all TR rooms are malformed like this (#3975) 2024-01-18 16:55:28 +00:00
Richard van der Hoff c885542628 Broaden spec version support (#4014)
This commit does two things:

 * It puts the "minimum supported matrix version" from v1.5 back down to
   v1.1. In other words, it is a partial revert of
   https://github.com/matrix-org/matrix-js-sdk/pull/3970. (Partial, because we
   don't need to update the tests.)

   We're doing this largely because
   https://github.com/matrix-org/matrix-js-sdk/pull/3970 was introduced without
   a suitable announcement and deprecation policy. We haven't yet decided if
   the js-sdk's spec support policy needs to change, or if we will re-introduce
   this change in future in a more graceful manner.

 * It increases the "maximum supported matrix version" from v1.5 up to
   v1.9. Previously, the two concepts were tied together, but as discussed at
   length in
   https://github.com/matrix-org/matrix-js-sdk/issues/3915#issuecomment-1865221366,
   this is incorrect.

   Unfortunately, we have no real way of testing whether it is true that the
   js-sdk actually works with a server which supports *only* v1.9, but as per
   the comment above, we can't do much about that.

Fixes https://github.com/matrix-org/matrix-js-sdk/issues/3915.
2024-01-18 16:34:01 +00:00
David Baker 81b58388ee Fix new threads not appearing. (#4009)
* Fix new threads not appearing.

We try to update the thread roots when creating a thread, but a thread
can take some time to be ready after being created so we were calling it
too soon. Add a listener for the Update event to update the thread roots
once it's ready.

Fixes https://github.com/element-hq/element-web/issues/26799

* Don't recreate the event when we update

and also add a comment to the test

* Hopefully make sonarcloud happy
2024-01-17 15:20:11 +00:00
Richard van der Hoff 0d486eaade Add CODEOWNERS entries for crypot stuff (#4012) 2024-01-17 10:49:03 +00:00
RiotRobot 76b9c3950b Resetting package fields for development 2024-01-16 17:37:37 +00:00
RiotRobot 6176cb6d7b Merge branch 'master' into develop 2024-01-16 17:37:35 +00:00
RiotRobot b9a107f9ff v31.0.0 2024-01-16 17:36:41 +00:00
Richard van der Hoff 06e8cea63d Emit events during migration from libolm (#3982)
* Fix `CryptoStore.countEndToEndSessions`

This was apparently never tested, and was implemented incorrectly.

* Add `CryptoStore.countEndToEndInboundGroupSessions`

* Emit events to indicate migration progress
2024-01-16 13:31:21 +00:00
Richard van der Hoff 815c36e075 Support for migration from from libolm (#3978)
* Use a `StoreHandle` to init OlmMachine

This will be faster if we need to prepare the store.

* Include "needsBackup" flag in inbound group session batches

* On startup, import data from libolm cryptostore

* ISessionExtended -> SessionExtended
2024-01-16 12:00:22 +00:00
Michael Telatynski d355073d10 Stop running react-sdk Cypress tests (#4008)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-16 08:19:14 +00:00
renovate[bot] 2ef3ebb466 Update dependency eslint-plugin-jest to v27.6.2 (#4003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-11 11:16:21 +00:00
renovate[bot] 92f7481fdd Update crazy-max/ghaction-import-gpg digest to 01dd5d3 (#4002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-11 11:10:35 +00:00
Andy Balaam 8df30ed068 Revert "Revert "Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989)" (#3991)" (#4001)
This reverts commit a597a9d660.
2024-01-10 16:30:59 +00:00
renovate[bot] 49624d5d73 Update all non-major dependencies (#3995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 10:19:12 +00:00
renovate[bot] 8e5128ad3c Update dependency eslint-plugin-unicorn to v50 (#4000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:22 +00:00
renovate[bot] 8ac2f2a78d Update dependency eslint-plugin-jsdoc to v48 (#3999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:21 +00:00
renovate[bot] 630440c59c Update babel monorepo to v7.23.7 (#3993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:17 +00:00
renovate[bot] 6932437360 Update dawidd6/action-download-artifact action to v3 (#3998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:14 +00:00
renovate[bot] f381dfe991 Update dependency @types/node to v18.19.4 (#3994)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:11 +00:00
renovate[bot] 6a98b835a8 Update typescript-eslint monorepo to v6.18.0 (#3996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:09 +00:00
RiotRobot ae01c0915c v31.0.0-rc.0 2024-01-09 17:46:58 +00:00
Andy Balaam a597a9d660 Revert "Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989)" (#3991)
This reverts commit 533070c603.
2024-01-08 16:35:30 +00:00
Richard van der Hoff 9661cdecf2 bump fake-indexeddb (#3990)
To pick up fix to https://github.com/dumbmatter/fakeIndexedDB/issues/94
2024-01-08 15:34:49 +00:00
Richard van der Hoff 533070c603 Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989) 2024-01-08 14:13:51 +00:00
David Langley eae1c2d48b Update teams names in CODEOWNERS (#3988)
* update teams names in CODEOWNERS

* Revert unintentional commit to client.ts
2024-01-08 14:08:47 +00:00
Richard van der Hoff 070a89d89d Bump minimum spec version to v1.5 (#3970)
* Update minimum spec version

* Update README.md

* fix autodiscovery tests
2024-01-08 12:33:13 +00:00
Rashmit Pankhania ffc9fb34d0 #22606 Fix "Remove" button to users without "m.room.redaction" (#3981)
* #22606 Fix "Remove" button  to users without "m.room.redaction" permission

This change makes the remove button NOT available to users without permissions

* Fix lint

Signed-off-by: Rashmit Pankhania <rashmitpankhania@gmail.com>

---------

Signed-off-by: Rashmit Pankhania <rashmitpankhania@gmail.com>
2024-01-03 12:21:38 +00:00
Richard van der Hoff d030c83cee Groundwork for supporting migration from libolm to rust crypto. (#3977)
* `getOwnDeviceKeys`: use `olmMachine.identityKeys`

This is simpler, and doesn't rely on us having done a device query to work.

* Factor out `requestKeyBackupVersion` utility

* Factor out `makeMatrixHttpApi` function

* Convert `initRustCrypto` to take a params object

* Improve logging in startup

... to help figure out what is taking so long.
2024-01-03 11:09:17 +00:00
renovate[bot] c115e055c6 Update typescript-eslint monorepo to v6 (major) (#3984)
* Update typescript-eslint monorepo to v6

* Fix typo

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-02 17:49:30 +00:00
renovate[bot] 0f65088fd9 Update dependency prettier to v3 (#3983)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-02 17:56:06 +00:00
Valere a1ff63adcb ElementR: Ensure Encryption order per room (#3973)
* add test for order bug

* Ensure encryption order per room

* Remove unneeded fake timers

* review

* put back log duration

* fix wrong call

* code review

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/RoomEncryptor.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/RoomEncryptor.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* fix link syntax

* remove xxx comment

* fix comment order

* Improve comment

* add log duration

* fix comment

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-02 12:50:46 +00:00
Lars Karbo 31fc5f23be Use enums for event listeners in the readme (#3979) 2024-01-02 11:32:07 +00:00
Hubert Chathi febef3fc7c Element-R: fix bootstrapSecretStorage not resetting key backup when requested (#3976)
* pull resetKeyBackup outside of if statement

* fix broken conflict resolution

* prettier
2023-12-29 14:19:11 +00:00
Michael Telatynski f2625348d8 Move sonarcloud shard support into reusable workflow (#3962)
* Fix typo in jest CI

Caused versions to clobber each other's LCOV

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Move sonarcloud shard support into reusable workflow

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-12-20 08:56:48 +00:00
David Baker 6d1d04782a Send authenticated /versions request (#3968)
* Send authenticated /versions request

Implements [MSC4026](https://github.com/matrix-org/matrix-spec-proposals/pull/4026).

I believe this probably is as simple as this: it will mean that the versions
response can obviously change after logging in, but since the client is
constructed again with an access token, this should just work (?)

A remaining question is whether this needs to be optional. Opening the PR
to prompt the discussion. Apps might not expect it, but it's just the same
auth that we're sending to other endpoints on the same server.

* Fix tests

* Clear /versions cache on access token set
2023-12-19 17:27:08 +00:00
Richard van der Hoff 5e67a173c8 Add new methods to CryptoStore for Rust Crypto migration (#3969)
* Add `CryptoStore.containsData`

* add `CryptoStore.{get,set}MigrationState`

* Implement `CryptoStore.getEndToEnd{,InboundGroup}SessionsBatch`

* Implement `CryptoStore.deleteEndToEnd{,InboundGroup}SessionsBatch`

* fix typedoc errors
2023-12-19 15:25:54 +00:00
RiotRobot 9780643ce7 Resetting package fields for development 2023-12-19 15:48:49 +00:00
RiotRobot 608c6ece56 Merge branch 'master' into develop 2023-12-19 15:48:48 +00:00
RiotRobot 3a55efb476 v30.3.0 2023-12-19 15:47:50 +00:00
Valere 48d4f1b0cc ElementR: Fix missing key check values in 4S key storage (#3950)
* fix missing key check in key storage

* code review

* fix tests

* add recovery keys test for both backends

* fix api break on GeneratedSecretStorageKey

* fix test

* fix test

* Update src/crypto-api.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/rust-crypto.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/crypto-api.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-12-18 15:05:28 +00:00
Valere a80e90b42d Add some perfomance logs (#3965)
* Add some perfomance logs

* missing return
2023-12-18 14:35:45 +00:00
Valere 2c13e133b7 ElementR: Ensure there is only one call to shareRoomKeys in flight at once (#3948)
* shareRoomKeys lock

* cleaning

* add test for lock
2023-12-18 09:26:00 +00:00
Andy Balaam 68898aeff2 Bump matrix-sdk-crypto-wasm to 3.5.0 (#3961) 2023-12-15 14:47:37 +00:00
David Baker f604ab2f63 Remove m.thread filter from relations API call (#3959)
* Remove m.thread filter from relations API call

We used MSC3981 to pass the recurse param to the /relations
endpoint so that we could get relations to events in a thread, but
we kept the rel_type filter on (as m.thread) so no second-order relations
would ever have been returned (a nested thread isn't a thing).

This removes the filter and does some filtering on the client side to
remove any events that shouldn't live in the threaded timeline (ie.
non-thread relations to the thread root event).

This should help fix stuck unreads because it will avoid the event that
the receipt refers to going missing (but only on HSes that support MSC3981).

For https://github.com/vector-im/element-web/issues/26718

* Fix import cycle

* Remove params from expected calls in tests to match

* Unused import
2023-12-14 10:39:43 +00:00
renovate[bot] b7d45e83f8 Update all non-major dependencies (#3955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 20:25:50 +00:00
renovate[bot] db0e3cfbb0 Update babel monorepo to v7.23.5 (#3953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 08:55:36 +00:00
renovate[bot] 87b90cc983 Update dawidd6/action-download-artifact digest to f29d1b6 (#3952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:29:52 +00:00
renovate[bot] cc9545e313 Update dependency @types/jest to v29.5.11 (#3954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:28:48 +00:00
renovate[bot] ed2792e6d8 Update dependency @types/node to v18.19.3 (#3956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:27:07 +00:00
104 changed files with 75844 additions and 1095 deletions
+11 -4
View File
@@ -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
+3 -5
View File
@@ -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
+2 -6
View File
@@ -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 }}"
+1 -1
View File
@@ -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 }}
+47 -6
View File
@@ -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()
+2 -28
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
+54
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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",
+38 -12
View File
@@ -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);
+190 -24
View File
@@ -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]: {
+67 -1
View File
@@ -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);
});
}
}
+23 -5
View File
@@ -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() {}
+11 -11
View File
@@ -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": {
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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({});
};
+1 -3
View File
@@ -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,
};
}
+1 -7
View File
@@ -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,
}),
});
+226
View File
@@ -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);
}
+4 -6
View File
@@ -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);
};
+2 -4
View File
@@ -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
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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;
+4 -1
View File
@@ -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> {
+4 -4
View File
@@ -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", () => {
+11 -3
View File
@@ -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);
+163 -10
View File
@@ -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");
});
});
});
+144
View File
@@ -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);
});
});
+346 -60
View File
@@ -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);
}
+18 -4
View File
@@ -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",
);
});
});
+51
View File
@@ -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]);
});
});
+4 -4
View File
@@ -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]: {
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+4 -1
View File
@@ -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
+1 -5
View File
@@ -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);
}
+10 -2
View File
@@ -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;
+4 -1
View File
@@ -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();
}
+8 -2
View File
@@ -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
View File
@@ -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();
+3 -1
View File
@@ -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;
}
+108
View File
@@ -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("-");
+97 -2
View File
@@ -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
/**
+179 -3
View File
@@ -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));
}
+146 -2
View File
@@ -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 {
+9 -6
View File
@@ -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
View File
@@ -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);
}
}
+4 -1
View File
@@ -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
View File
@@ -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)
+4 -1
View File
@@ -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);
}
}
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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.
*/
+6 -2
View File
@@ -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
View File
@@ -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 + ":";
}
+4 -1
View File
@@ -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");
+1 -1
View File
@@ -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)) {
+4 -1
View File
@@ -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");
+2 -3
View File
@@ -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
View File
@@ -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.");
+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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;
+5 -1
View File
@@ -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,
) {}
}
/**
+7 -2
View File
@@ -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);
}
+4 -1
View File
@@ -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
View File
@@ -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);
+4 -1
View File
@@ -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
View File
@@ -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!) {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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);
}
}
+4 -1
View File
@@ -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);
+6 -2
View File
@@ -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`);
}
}
+4 -2
View File
@@ -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.
+77 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+274
View File
@@ -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);
}
+40 -50
View File
@@ -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);
+13 -7
View File
@@ -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) {
+9 -8
View File
@@ -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) {
+4 -1
View File
@@ -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";
+4 -1
View File
@@ -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();
}
+4 -1
View File
@@ -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
View File
@@ -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);
+31
View File
@@ -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;
}
+4 -1
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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