Compare commits

..

87 Commits

Author SHA1 Message Date
RiotRobot 121250a6fb v20.1.0-rc.2 2022-10-05 13:23:42 +01:00
RiotRobot a7aa227f55 Prepare changelog for v20.1.0-rc.2 2022-10-05 13:23:42 +01:00
ElementRobot 04c1dfe43a Use the correct sender key when checking shared secret (#2730) (#2731)
(cherry picked from commit 890a840685)

Co-authored-by: Hubert Chathi <hubertc@matrix.org>
2022-10-04 11:27:40 -06:00
RiotRobot 07476a0ae0 v20.1.0-rc.1 2022-10-04 14:01:35 +01:00
RiotRobot 6348704bec Prepare changelog for v20.1.0-rc.1 2022-10-04 14:01:34 +01:00
mcalinghee f84a33910c Unexpected ignored self key request when it's not shared history (#2724)
* ignore forwarded key process also if the user is not the same
2022-10-04 14:31:21 +02:00
Germain 0ccf7c50f2 Add custom error message for restricted import (#2727) 2022-10-04 09:59:20 +01:00
Šimon Brandner ead33003b7 Check for the spec version when determining private read receipt support (#2722) 2022-10-04 06:50:42 +00:00
Faye Duxovni 7d5360a00f Rename redecryption-related function arguments for clarity (#2709) 2022-10-03 19:51:30 -04:00
renovate[bot] 4b283015ba Lock file maintenance (#2721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-03 08:30:28 +01:00
Michael Telatynski 887e15aac5 Fix IDB initial migration handling causing spurious lazy loading upgrade loops (#2718) 2022-09-30 10:41:04 +00:00
RiotRobot a83d80f502 Merge branch 'master' into develop 2022-09-30 10:59:52 +01:00
RiotRobot 6166a8f7fd v20.0.2 2022-09-30 10:58:34 +01:00
RiotRobot 3efc18cfde Prepare changelog for v20.0.2 2022-09-30 10:58:33 +01:00
ElementRobot 5520aa3e2a [Backport staging] Fix issue in sync when crypto is not supported by client (#2716)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Stanislav Demydiuk <stas-demydiuk@users.noreply.github.com>
2022-09-30 10:57:04 +01:00
Michael Telatynski 5afe373446 Fix release-npm.yml dist-tag npm token passing mechanism (#2717) 2022-09-30 10:56:36 +01:00
Stanislav Demydiuk 9bb5afe5c0 Fix issue in sync when crypto is not supported by client (#2715)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-09-30 08:12:31 +00:00
Michael Telatynski f349663329 Add CI to protect against mixing src and lib imports (#2704) 2022-09-30 09:05:28 +01:00
Faye Duxovni f398e3564d Calculate IndexedDB versions automatically to reduce repeated information and possibilities for error (#2713) 2022-09-29 16:25:19 -04:00
RiotRobot 91171afddd Merge branch 'master' into develop 2022-09-28 17:48:45 +01:00
RiotRobot b54c9d689a v20.0.1 2022-09-28 17:47:24 +01:00
RiotRobot 4e69d7c9ac Prepare changelog for v20.0.1 2022-09-28 17:47:23 +01:00
ElementRobot bf9f595984 [Backport staging] Fix missing return when receiving an invitation without shared history (#2711)
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-09-28 16:40:58 +00:00
Faye Duxovni f410e71bfa Fix missing return when receiving an invitation without shared history (#2710) 2022-09-28 17:36:09 +01:00
RiotRobot 83fca5b57d Merge branch 'master' into develop 2022-09-28 15:46:11 +01:00
RiotRobot 90052670e7 v20.0.0 2022-09-28 15:44:47 +01:00
RiotRobot db49a1a623 Prepare changelog for v20.0.0 2022-09-28 15:44:47 +01:00
ElementRobot 45330c6418 [Backport staging] Bump IDB crypto store version (#2708)
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-09-28 15:43:43 +01:00
Faye Duxovni 4ba083e6af Bump IDB crypto store version (#2705)
* Bump IDB crypto store version

* lint fix
2022-09-28 15:39:37 +01:00
Germain 0403e4bedc Fix incorrect MSC3890 unstable prefix (#2703) 2022-09-28 15:37:13 +02:00
RiotRobot 14aa7846a5 Merge branch 'master' into develop 2022-09-28 14:05:21 +01:00
RiotRobot 2d067ad957 v19.7.0 2022-09-28 14:03:53 +01:00
RiotRobot 418aa3ff6a Prepare changelog for v19.7.0 2022-09-28 14:03:52 +01:00
RiotRobot a587d7c360 Resolve multiple CVEs
CVE-2022-39249
CVE-2022-39250
CVE-2022-39251
CVE-2022-39236
2022-09-28 13:55:15 +01:00
RiotRobot 45348a354e Resetting package fields for development 2022-09-27 16:48:03 +01:00
RiotRobot fa3339fc84 Merge branch 'master' into develop 2022-09-27 16:48:00 +01:00
RiotRobot b64a30f0ad v19.6.0 2022-09-27 16:46:53 +01:00
RiotRobot 5451f6139a Prepare changelog for v19.6.0 2022-09-27 16:46:52 +01:00
Germain b332c6c4b9 Account data can return undefined (#2701) 2022-09-27 12:47:05 +00:00
Germain 209a101be7 Add local notification settings capability (#2700) 2022-09-27 11:41:20 +01:00
Michael Telatynski efbf5479d1 Remove redundant rel_branch logic (#2698) 2022-09-26 10:05:58 +01:00
Hugh Nimmo-Smith dacef048be Typings for MSC3824 (#2691) 2022-09-23 16:23:50 -06:00
Hugh Nimmo-Smith caadc6f95b Implementation of MSC3882 login token request (#2687) 2022-09-22 16:36:37 +01:00
Hugh Nimmo-Smith 39bc7e2bb3 Refer to explicit Matrix spec versions instead of Latest (#2668) 2022-09-22 15:28:11 +00:00
Hugh Nimmo-Smith 040f012350 Typings for MSC2965 OIDC provider discovery (#2424) 2022-09-22 16:27:13 +01:00
Hugh Nimmo-Smith 8b2b677f92 Add return type for loginFlows() (#2669)
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-09-22 16:25:01 +01:00
Germain 2b1fab928b Add unstable device_id field for MSC3881 (#2688) 2022-09-22 15:44:54 +02:00
Germain 516f52c5a4 Support to remotely toggle push notifications (#2686) 2022-09-22 08:37:54 +00:00
Michael Telatynski 8599a98b47 Fix backpagination at end logic being spec non-conforming (#2680) 2022-09-21 16:35:07 +01:00
renovate[bot] 7e24cb6cae Update jest monorepo to v29.0.3 (#2682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 09:25:43 +01:00
renovate[bot] 38aa8d18c0 Update all (#2684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 09:13:36 +01:00
Germain 2967ee6309 Read receipts for threads (#2635) 2022-09-21 07:50:44 +00:00
renovate[bot] 2e10b6065c Update typescript-eslint monorepo to v5.38.0 (#2685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 08:42:30 +02:00
renovate[bot] 69057ee035 Update babel monorepo to v7.19.1 (#2681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 08:25:16 +02:00
RiotRobot f34e568a98 v19.6.0-rc.1 2022-09-20 13:55:51 +01:00
RiotRobot 6fd80ed3ed Prepare changelog for v19.6.0-rc.1 2022-09-20 13:55:50 +01:00
Kerry 9ff11d1f32 test typescriptification - last few unit test files (#2675)
* renamed:    spec/unit/crypto/verification/sas.spec.js -> spec/unit/crypto/verification/sas.spec.ts

* ts issues in sas.spec

* renamed:    spec/unit/crypto/verification/secret_request.spec.js -> spec/unit/crypto/verification/secret_request.spec.ts

* ts issues in secret_request.spec

* renamed:    spec/unit/crypto/verification/verification_request.spec.js -> spec/unit/crypto/verification/verification_request.spec.ts

* ts fix verification_req.spec

* renamed:    spec/browserify/sync-browserify.spec.js -> spec/browserify/sync-browserify.spec.ts

* fix strict

* formatting
2022-09-16 16:00:40 +00:00
Kerry 41ab3337b5 test typescriptification - spec/unit/crypto/verification (#2673)
* renamed:    spec/unit/crypto/verification/request.spec.js -> spec/unit/crypto/verification/request.spec.ts

* renamed:    spec/unit/crypto/verification/qr_code.spec.js -> spec/unit/crypto/verification/qr_code.spec.ts

* renamed:    spec/unit/crypto/verification/InRoomChannel.spec.js -> spec/unit/crypto/verification/InRoomChannel.spec.ts

* fix ts issues in InRoomChannel.spec

* renamed:    spec/unit/crypto/verification/util.js -> spec/unit/crypto/verification/util.ts

* fix ts issues in util.t

* fix strict errors in util.ts

* js lint
2022-09-16 10:46:56 +02:00
Michael Telatynski 1432e094c2 Update sonarcloud.yml 2022-09-15 14:26:22 +01:00
Michael Telatynski e09936cc9b Update sonarcloud.yml (#2671) 2022-09-15 14:16:15 +01:00
Germain d32d190a8a Change return type to avoid null strict error (#2630) 2022-09-14 09:02:16 +01:00
Michael Telatynski 53de8d5690 Fix reset_dependency for if the dep is indirect (#2664)
Like for in element-desktop
2022-09-14 08:57:52 +01:00
Michael Telatynski 829110b580 Update release-npm.yml (#2665) 2022-09-14 08:57:36 +01:00
RiotRobot 59c82cb679 Resetting package fields for development 2022-09-13 12:32:41 +01:00
RiotRobot 6d3741d55c Merge branch 'master' into develop 2022-09-13 12:32:37 +01:00
Šimon Brandner fb565f301b Remove support for unstable private read receipts (#2624) 2022-09-12 18:04:14 +02:00
Michael Telatynski afa3b37ad5 Revert "Add login flow types from matrix-react-sdk (#2633)" (#2661)
* Revert "Add login flow types from matrix-react-sdk (#2633)"

This reverts commit 583e4808

* Leave login flow types, only revert method return type

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
2022-09-12 11:25:39 +01:00
Hugh Nimmo-Smith a57c430b09 Implementation of MSC3824 to add action= param on SSO login (#2398)
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-11 21:18:33 +00:00
Hugh Nimmo-Smith 583e48086b Add login flow types from matrix-react-sdk (#2633)
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-09-11 21:50:10 +01:00
Robin b22c671fee Add a property aggregating all names of a NamespacedValue (#2656)
For convenience
2022-09-09 09:55:17 -04:00
David Baker 1b86acb2fb Fix import locations (#2655)
These were causing circular references in the group call branch
2022-09-08 20:51:42 +01:00
renovate[bot] d2f7a2575e Update all (major) (#2651)
* Update all

* Pin p-retry once more

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-09-08 09:52:50 +00:00
Michael Telatynski 8490f72488 Tweak backport labels (#2652) 2022-09-07 11:51:11 +00:00
kegsay d87e53858b Merge pull request #2628 from matrix-org/kegan/ss-member-counts
sliding sync: add invited|joined_count
2022-09-07 11:22:47 +02:00
David Teller 917e8c01d8 Base support for MSC3847: Ignore invites with policy rooms (#2626)
* Base support for MSC3847: Ignore invites with policy rooms

Type: enhancement

* Base support for MSC3847: Ignore invites with policy rooms

Type: enhancement

* WIP: Applying feedback

* WIP: Applying feedback

* WIP: CI linter gives me different errors, weird

* WIP: A little more linting
2022-09-06 22:17:42 -06:00
renovate[bot] eb3309db43 Update jest monorepo to v29 (#2649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-06 22:02:40 -06:00
renovate[bot] 65741d7860 Update all (#2647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-06 22:05:37 +01:00
renovate[bot] a7a264f4e7 Update typescript-eslint monorepo to v5.36.2 (#2645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-06 17:53:05 +01:00
renovate[bot] 0fa125b60a Update babel monorepo to v7.19.0 (#2644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-06 17:52:57 +01:00
Michael Telatynski 8aee884d03 Fix handling of remote echoes doubling up (#2639)
* Fix handling of remote echoes doubling up

* Simplify code

* Make TSC strict happier

* Update `timelineWasEmpty` to match type

* Add tests

* Add tests

* Add lowly `!`
2022-09-06 13:27:24 +01:00
Michael Telatynski 876491e38d Remove redundant concurrency 2022-09-06 12:36:54 +01:00
Kegan Dougal ac7f505a2b Use the right nulls 2022-08-30 18:13:37 +01:00
Kegan Dougal b5576758e4 Even more strict TS type checking 2022-08-30 18:07:53 +01:00
Kegan Dougal c32a83fdac Linting 2022-08-30 18:02:56 +01:00
Kegan Dougal 2d9556c1bb More strict TS type checking 2022-08-30 18:00:03 +01:00
Kegan Dougal 818b70554a Strict TS checks 2022-08-30 17:50:48 +01:00
Kegan Dougal 725336ffc6 sliding sync: add invited|joined_count
This is critical for calculating client-side push rules correctly.
Without it, the push processor may think rooms have a different
number of members, resulting typically in annoying failure modes
where rooms constantly make noises because the code thinks they
are 1:1 rooms.
2022-08-30 17:41:54 +01:00
86 changed files with 4808 additions and 1498 deletions
+21 -1
View File
@@ -1,14 +1,22 @@
module.exports = {
plugins: [
"matrix-org",
"import",
],
extends: [
"plugin:matrix-org/babel",
"plugin:import/typescript",
],
env: {
browser: true,
node: true,
},
settings: {
"import/resolver": {
typescript: true,
node: true,
},
},
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: {
@@ -35,7 +43,19 @@ module.exports = {
"no-console": "error",
// restrict EventEmitters to force callers to use TypedEventEmitter
"no-restricted-imports": ["error", "events"],
"no-restricted-imports": ["error", {
name: "events",
message: "Please use TypedEventEmitter instead"
}],
"import/no-restricted-paths": ["error", {
"zones": [{
"target": "./src/",
"from": "./src/index.ts",
"message": "The package index is dynamic between src and lib depending on " +
"whether release or development, target the specific module or matrix.ts instead",
}],
}],
},
overrides: [{
files: [
+1 -1
View File
@@ -25,6 +25,6 @@ jobs:
steps:
- uses: tibdex/backport@v2
with:
labels_template: "<%= JSON.stringify(labels) %>"
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
github_token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+3 -1
View File
@@ -17,6 +17,7 @@ jobs:
uses: actions/setup-node@v3
with:
cache: "yarn"
registry-url: 'https://registry.npmjs.org'
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
@@ -35,5 +36,6 @@ jobs:
package=$(cat package.json | jq -er .name)
npm dist-tag add "$package@$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc
INPUT_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.version }}
+21
View File
@@ -10,7 +10,18 @@ jobs:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
- uses: Sibz/github-status-action@v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
context: ${{ github.workflow }} / SonarCloud (${{ github.event.workflow_run.event }} => ${{ github.event_name }})
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.2
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
@@ -22,3 +33,13 @@ jobs:
coverage_run_id: ${{ github.event.workflow_run.id }}
coverage_workflow_name: tests.yml
coverage_extract_path: coverage
- uses: Sibz/github-status-action@v1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: ${{ steps.sonarcloud.outcome == 'success' && 'success' || 'failure' }}
context: ${{ github.workflow }} / SonarCloud (${{ github.event.workflow_run.event }} => ${{ github.event_name }})
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+11 -1
View File
@@ -23,6 +23,16 @@ jobs:
- name: Typecheck
run: "yarn run lint:types"
- name: Switch js-sdk to release mode
run: |
scripts/switch_package_to_release.js
yarn install
yarn run build:compile
yarn run build:types
- name: Typecheck (release mode)
run: "yarn run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
@@ -73,7 +83,7 @@ jobs:
- name: Detecting files changed
id: files
uses: futuratrepadeira/changed-files@v3.2.1
uses: futuratrepadeira/changed-files@v4.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pattern: '^.*\.tsx?$'
+60
View File
@@ -1,3 +1,63 @@
Changes in [20.1.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.1.0-rc.2) (2022-10-05)
============================================================================================================
## 🐛 Bug Fixes
* Use the correct sender key when checking shared secret ([\#2730](https://github.com/matrix-org/matrix-js-sdk/pull/2730)). Fixes vector-im/element-web#23374.
Changes in [20.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.1.0-rc.1) (2022-10-04)
============================================================================================================
## ✨ Features
* Add local notification settings capability ([\#2700](https://github.com/matrix-org/matrix-js-sdk/pull/2700)).
* Implementation of MSC3882 login token request ([\#2687](https://github.com/matrix-org/matrix-js-sdk/pull/2687)). Contributed by @hughns.
* Typings for MSC2965 OIDC provider discovery ([\#2424](https://github.com/matrix-org/matrix-js-sdk/pull/2424)). Contributed by @hughns.
* Support to remotely toggle push notifications ([\#2686](https://github.com/matrix-org/matrix-js-sdk/pull/2686)).
* Read receipts for threads ([\#2635](https://github.com/matrix-org/matrix-js-sdk/pull/2635)).
## 🐛 Bug Fixes
* Unexpected ignored self key request when it's not shared history ([\#2724](https://github.com/matrix-org/matrix-js-sdk/pull/2724)). Contributed by @mcalinghee.
* Fix IDB initial migration handling causing spurious lazy loading upgrade loops ([\#2718](https://github.com/matrix-org/matrix-js-sdk/pull/2718)). Fixes vector-im/element-web#23377.
* Fix backpagination at end logic being spec non-conforming ([\#2680](https://github.com/matrix-org/matrix-js-sdk/pull/2680)). Fixes vector-im/element-web#22784.
Changes in [20.0.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.2) (2022-09-30)
==================================================================================================
## 🐛 Bug Fixes
* Fix issue in sync when crypto is not supported by client ([\#2715](https://github.com/matrix-org/matrix-js-sdk/pull/2715)). Contributed by @stas-demydiuk.
Changes in [20.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.1) (2022-09-28)
==================================================================================================
## 🐛 Bug Fixes
* Fix missing return when receiving an invitation without shared history ([\#2710](https://github.com/matrix-org/matrix-js-sdk/pull/2710)).
Changes in [20.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.0) (2022-09-28)
==================================================================================================
## 🚨 BREAKING CHANGES
* Bump IDB crypto store version ([\#2705](https://github.com/matrix-org/matrix-js-sdk/pull/2705)).
Changes in [19.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.7.0) (2022-09-28)
==================================================================================================
## 🔒 Security
* Fix for [CVE-2022-39249](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39249)
* Fix for [CVE-2022-39250](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39250)
* Fix for [CVE-2022-39251](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39251)
* Fix for [CVE-2022-39236](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39236)
Changes in [19.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.6.0) (2022-09-27)
==================================================================================================
## ✨ Features
* Add a property aggregating all names of a NamespacedValue ([\#2656](https://github.com/matrix-org/matrix-js-sdk/pull/2656)).
* Implementation of MSC3824 to add action= param on SSO login ([\#2398](https://github.com/matrix-org/matrix-js-sdk/pull/2398)). Contributed by @hughns.
* Add invited_count and joined_count to sliding sync room responses. ([\#2628](https://github.com/matrix-org/matrix-js-sdk/pull/2628)).
* Base support for MSC3847: Ignore invites with policy rooms ([\#2626](https://github.com/matrix-org/matrix-js-sdk/pull/2626)). Contributed by @Yoric.
## 🐛 Bug Fixes
* Fix handling of remote echoes doubling up ([\#2639](https://github.com/matrix-org/matrix-js-sdk/pull/2639)). Fixes #2618.
Changes in [19.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.5.0) (2022-09-13)
==================================================================================================
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "19.5.0",
"version": "20.1.0-rc.2",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
@@ -81,24 +81,25 @@
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/jest": "^28.0.0",
"@types/jest": "^29.0.0",
"@types/node": "16",
"@types/request": "^2.48.5",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"allchange": "^1.0.6",
"babel-jest": "^28.0.0",
"babel-jest": "^29.0.0",
"babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"eslint": "8.22.0",
"eslint": "8.23.1",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-matrix-org": "^0.6.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"jest": "^28.0.0",
"jest": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
+4 -13
View File
@@ -96,6 +96,9 @@ function check_dependency {
}
function reset_dependency {
local depver=$(cat package.json | jq -r .dependencies[\"$1\"])
if [ "$depver" == "null" ]; then return 0; fi
echo "Resetting $1 to develop branch..."
yarn add "github:matrix-org/$1#develop"
git add -u
@@ -132,7 +135,6 @@ yarn install --ignore-scripts --pure-lockfile
# ignore leading v on release
release="${1#v}"
tag="v${release}"
rel_branch="release-$tag"
prerelease=0
# We check if this build is a prerelease by looking to
@@ -147,18 +149,7 @@ else
read -p "Making a FINAL RELEASE, press enter to continue " REPLY
fi
# We might already be on the release branch, in which case, yay
# If we're on any branch starting with 'release', or the staging branch
# we don't create a separate release branch (this allows us to use the same
# release branch for releases and release candidates).
curbranch=$(git symbolic-ref --short HEAD)
if [[ "$curbranch" != release* && "$curbranch" != "staging" ]]; then
echo "Creating release branch"
git checkout -b "$rel_branch"
else
echo "Using current branch ($curbranch) for release"
rel_branch=$curbranch
fi
rel_branch=$(git symbolic-ref --short HEAD)
if [ -z "$skip_changelog" ]; then
echo "Generating changelog"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
const fsProm = require('fs/promises');
const PKGJSON = 'package.json';
async function main() {
const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8'));
for (const field of ['main', 'typings']) {
if (pkgJson["matrix_lib_"+field] !== undefined) {
pkgJson[field] = pkgJson["matrix_lib_"+field];
}
}
await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2));
}
main();
+7 -1
View File
@@ -132,7 +132,7 @@ async function aliDownloadsKeys(): Promise<void> {
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
await Promise.all([p1(), p2()]);
await aliTestClient.client.crypto.deviceList.saveIfDirty();
await aliTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore - protected
aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId];
@@ -494,6 +494,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -504,6 +505,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -567,6 +569,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -584,6 +587,9 @@ describe("MatrixClient crypto", () => {
await firstSync(bobTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
bobTestClient.httpBackend.when('POST', '/keys/query').respond(
200, {},
);
await bobRecvMessage();
await bobEnablesEncryption();
const ciphertext = await bobSendsReplyMessage();
@@ -833,6 +833,45 @@ describe("MatrixClient event timelines", function() {
]);
});
it("should stop paginating when it encounters no `end` token", () => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" +
encodeURIComponent(EVENTS[0].event_id))
.respond(200, () => ({
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
}));
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.check(function(req) {
const params = req.queryParams;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
}).respond(200, () => ({
start: "start_token0",
chunk: [],
}));
return Promise.all([
(async () => {
const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id);
const success = await client.paginateEventTimeline(tl, { backwards: true });
expect(success).toBeFalsy();
expect(tl.getEvents().length).toEqual(1);
expect(tl.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null);
expect(tl.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
})(),
httpBackend.flushAllExpected(),
]);
});
it("should allow you to paginate forwards", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
+24
View File
@@ -889,6 +889,7 @@ describe("MatrixClient", function() {
};
const prom = client.getPushers();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {});
httpBackend.when("GET", "/pushers").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
@@ -1074,6 +1075,29 @@ describe("MatrixClient", function() {
expect(await prom).toStrictEqual(response);
});
});
describe("requestLoginToken", () => {
it("should hit the expected API endpoint with UIA", async () => {
const response = {};
const uiaData = { foo: "baa" };
const prom = client.requestLoginToken(uiaData);
httpBackend
.when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData })
.respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
it("should hit the expected API endpoint without UIA", async () => {
const response = {};
const prom = client.requestLoginToken();
httpBackend
.when("POST", "/unstable/org.matrix.msc3882/login/token", {})
.respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
});
function withThreadId(event, newThreadId) {
+111
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import 'fake-indexeddb/auto';
import { Optional } from "matrix-events-sdk/lib/types";
import HttpBackend from "matrix-mock-request";
@@ -25,6 +27,8 @@ import {
RoomMemberEvent,
UNSTABLE_MSC2716_MARKER,
MatrixClient,
ClientEvent,
IndexedDBCryptoStore,
} from "../../src";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -87,6 +91,8 @@ describe("MatrixClient syncing", () => {
});
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
await client.initCrypto();
const roomId = "!cycles:example.org";
// First sync: an invite
@@ -219,6 +225,44 @@ describe("MatrixClient syncing", () => {
return httpBackend!.flushAllExpected();
});
it("should emit ClientEvent.Room when invited while crypto is disabled", async () => {
const roomId = "!invite:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
httpBackend!.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
client!.once(ClientEvent.Room, (room) => {
fires++;
expect(room.roomId).toBe(roomId);
});
// noinspection ES6MissingAwait
client!.startClient();
await httpBackend!.flushAllExpected();
expect(fires).toBe(1);
});
});
describe("initial sync", () => {
@@ -1426,3 +1470,70 @@ describe("MatrixClient syncing", () => {
return utils.syncPromise(client, numSyncs);
}
});
describe("MatrixClient syncing (IndexedDB version)", () => {
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const syncData = {
next_batch: "batch_token",
rooms: {},
presence: {},
};
it("should emit ClientEvent.Room when invited while using indexeddb crypto store", async () => {
const idbTestClient = new TestClient(
selfUserId,
"DEVICE",
selfAccessToken,
undefined,
{ cryptoStore: new IndexedDBCryptoStore(global.indexedDB, "tests") },
);
const idbHttpBackend = idbTestClient.httpBackend;
const idbClient = idbTestClient.client;
idbHttpBackend.when("GET", "/versions").respond(200, {});
idbHttpBackend.when("GET", "/pushrules").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
await idbClient.initCrypto();
const roomId = "!invite:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
idbHttpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
idbClient.once(ClientEvent.Room, (room) => {
fires++;
expect(room.roomId).toBe(roomId);
});
// noinspection ES6MissingAwait
idbClient.startClient();
await idbHttpBackend.flushAllExpected();
expect(fires).toBe(1);
idbHttpBackend.verifyNoOutstandingExpectation();
idbClient.stopClient();
idbHttpBackend.stop();
});
});
+316 -1
View File
@@ -29,8 +29,11 @@ import {
IDownloadKeyResult,
MatrixEvent,
MatrixEventEvent,
IndexedDBCryptoStore,
Room,
} from "../../src/matrix";
import { IDeviceKeys } from "../../src/crypto/dehydration";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
const ROOM_ID = "!room:id";
@@ -280,10 +283,13 @@ describe("megolm", () => {
it("Alice receives a megolm message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -326,10 +332,13 @@ describe("megolm", () => {
it("Alice receives a megolm message before the session keys", async () => {
// https://github.com/vector-im/element-web/issues/2273
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event, but don't send it yet
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -383,10 +392,13 @@ describe("megolm", () => {
it("Alice gets a second room_key message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted1 = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -468,6 +480,9 @@ describe("megolm", () => {
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
await Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test').then(() => {
@@ -541,13 +556,16 @@ describe("megolm", () => {
logger.log('Forcing alice to download our device keys');
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
await Promise.all([
aliceTestClient.client.downloadKeys(['@bob:xyz']),
aliceTestClient.httpBackend.flush('/keys/query', 1),
aliceTestClient.httpBackend.flush('/keys/query', 2),
]);
logger.log('Telling alice to block our device');
@@ -592,6 +610,9 @@ describe("megolm", () => {
logger.log("Fetching bob's devices and marking known");
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
@@ -786,6 +807,10 @@ describe("megolm", () => {
logger.log('Forcing alice to download our device keys');
const downloadPromise = aliceTestClient.client.downloadKeys(['@bob:xyz']);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
// so will this.
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, 'test')
.then(() => {
@@ -805,9 +830,12 @@ describe("megolm", () => {
it("Alice exports megolm keys and imports them to a new device", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} });
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
// establish an olm session with alice
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
@@ -855,6 +883,8 @@ describe("megolm", () => {
await aliceTestClient.client.importRoomKeys(exported);
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
const syncResponse = {
next_batch: 1,
rooms: {
@@ -927,10 +957,13 @@ describe("megolm", () => {
it("Alice can decrypt a message with falsey content", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -985,10 +1018,13 @@ describe("megolm", () => {
"should successfully decrypt bundled redaction events that don't include a room_id in their /sync data",
async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -1045,4 +1081,283 @@ describe("megolm", () => {
expect(redactionEvent.content.reason).toEqual("redaction test");
},
);
it("Alice receives shared history before being invited to a room by the sharer", async () => {
const beccaTestClient = new TestClient(
"@becca:localhost", "foobar", "bazquux",
);
await beccaTestClient.client.initCrypto();
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
await beccaTestClient.start();
const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {});
beccaTestClient.client.store.storeRoom(beccaRoom);
await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" });
const event = new MatrixEvent({
type: "m.room.message",
sender: "@becca:localhost",
room_id: ROOM_ID,
event_id: "$1",
content: {
msgtype: "m.text",
body: "test message",
},
});
await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
const device = new DeviceInfo(beccaTestClient.client.deviceId);
aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device;
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId();
// Create an olm session for Becca and Alice's devices
const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload();
const aliceOtkId = Object.keys(aliceOtks)[0];
const aliceOtk = aliceOtks[aliceOtkId];
const p2pSession = new global.Olm.Session();
await beccaTestClient.client.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => {
const account = new global.Olm.Account();
try {
account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount);
p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key);
} finally {
account.free();
}
});
},
);
const content = event.getWireContent();
const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey(
ROOM_ID,
content.sender_key,
content.session_id,
);
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderKey: beccaTestClient.getDeviceKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
plaincontent: {
"algorithm": 'm.megolm.v1.aes-sha2',
"room_id": ROOM_ID,
"sender_key": content.sender_key,
"sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key,
"session_id": content.session_id,
"session_key": groupSessionKey.key,
"chain_index": groupSessionKey.chain_index,
"forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain,
"org.matrix.msc3061.shared_history": true,
},
plaintype: 'm.forwarded_room_key',
});
// Alice receives shared history
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
to_device: { events: [encryptedForwardedKey] },
});
await aliceTestClient.flushSync();
// Alice is invited to the room by Becca
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [
{
sender: '@becca:localhost',
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
},
{
sender: '@becca:localhost',
type: 'm.room.member',
state_key: '@alice:localhost',
content: {
membership: 'invite',
},
},
] } } } },
});
await aliceTestClient.flushSync();
// Alice has joined the room
aliceTestClient.httpBackend.when("GET", "/sync").respond(
200, getSyncResponse(["@alice:localhost", "@becca:localhost"]),
);
await aliceTestClient.flushSync();
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 4,
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [event.event] } },
},
},
});
await aliceTestClient.flushSync();
const room = aliceTestClient.client.getRoom(ROOM_ID);
const roomEvent = room.getLiveTimeline().getEvents()[0];
expect(roomEvent.isEncrypted()).toBe(true);
const decryptedEvent = await testUtils.awaitDecryption(roomEvent);
expect(decryptedEvent.getContent().body).toEqual('test message');
await beccaTestClient.stop();
});
it("Alice receives shared history before being invited to a room by someone else", async () => {
const beccaTestClient = new TestClient(
"@becca:localhost", "foobar", "bazquux",
);
await beccaTestClient.client.initCrypto();
await aliceTestClient.start();
await beccaTestClient.start();
const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {});
beccaTestClient.client.store.storeRoom(beccaRoom);
await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" });
const event = new MatrixEvent({
type: "m.room.message",
sender: "@becca:localhost",
room_id: ROOM_ID,
event_id: "$1",
content: {
msgtype: "m.text",
body: "test message",
},
});
await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
const device = new DeviceInfo(beccaTestClient.client.deviceId);
aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device;
// Create an olm session for Becca and Alice's devices
const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload();
const aliceOtkId = Object.keys(aliceOtks)[0];
const aliceOtk = aliceOtks[aliceOtkId];
const p2pSession = new global.Olm.Session();
await beccaTestClient.client.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => {
const account = new global.Olm.Account();
try {
account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount);
p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key);
} finally {
account.free();
}
});
},
);
const content = event.getWireContent();
const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey(
ROOM_ID,
content.sender_key,
content.session_id,
);
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderKey: beccaTestClient.getDeviceKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
plaincontent: {
"algorithm": 'm.megolm.v1.aes-sha2',
"room_id": ROOM_ID,
"sender_key": content.sender_key,
"sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key,
"session_id": content.session_id,
"session_key": groupSessionKey.key,
"chain_index": groupSessionKey.chain_index,
"forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain,
"org.matrix.msc3061.shared_history": true,
},
plaintype: 'm.forwarded_room_key',
});
// Alice receives forwarded history from Becca
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
to_device: { events: [encryptedForwardedKey] },
});
await aliceTestClient.flushSync();
// Alice is invited to the room by Charlie
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [
{
sender: '@becca:localhost',
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
},
{
sender: '@charlie:localhost',
type: 'm.room.member',
state_key: '@alice:localhost',
content: {
membership: 'invite',
},
},
] } } } },
});
await aliceTestClient.flushSync();
// Alice has joined the room
aliceTestClient.httpBackend.when("GET", "/sync").respond(
200, getSyncResponse(["@alice:localhost", "@becca:localhost", "@charlie:localhost"]),
);
await aliceTestClient.flushSync();
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 4,
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [event.event] } },
},
},
});
await aliceTestClient.flushSync();
// Decryption should fail, because Alice hasn't received any keys she can trust
const room = aliceTestClient.client.getRoom(ROOM_ID);
const roomEvent = room.getLiveTimeline().getEvents()[0];
expect(roomEvent.isEncrypted()).toBe(true);
const decryptedEvent = await testUtils.awaitDecryption(roomEvent);
expect(decryptedEvent.isDecryptionFailure()).toBe(true);
await beccaTestClient.stop();
});
});
+48
View File
@@ -168,6 +168,7 @@ describe("SlidingSyncSdk", () => {
const roomD = "!d_with_notif_count:localhost";
const roomE = "!e_with_invite:localhost";
const roomF = "!f_calc_room_name:localhost";
const roomG = "!g_join_invite_counts:localhost";
const data: Record<string, MSC3575RoomData> = {
[roomA]: {
name: "A",
@@ -261,12 +262,25 @@ describe("SlidingSyncSdk", () => {
],
initial: true,
},
[roomG]: {
name: "G",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
],
joined_count: 5,
invited_count: 2,
initial: true,
},
};
it("can be created with required_state and timeline", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomA].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
@@ -276,6 +290,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
const gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomB].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
@@ -285,6 +300,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(data[roomC].highlight_count);
@@ -294,15 +310,26 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(data[roomD].notification_count);
});
it("can be created with an invited/joined_count", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
const gotRoom = client.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
});
it("can be created with invite_state", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
const gotRoom = client.getRoom(roomE);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
});
@@ -311,6 +338,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
const gotRoom = client.getRoom(roomF);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.name,
).toEqual(data[roomF].name);
@@ -326,6 +354,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
@@ -333,6 +362,8 @@ describe("SlidingSyncSdk", () => {
it("can update with a new required_state event", async () => {
let gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, {
required_state: [
@@ -343,6 +374,7 @@ describe("SlidingSyncSdk", () => {
});
gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
});
@@ -355,6 +387,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(1);
@@ -369,11 +402,25 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(1);
});
it("can update with a new joined_count", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, {
name: data[roomD].name,
required_state: [],
timeline: [],
joined_count: 1,
});
const gotRoom = client.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
});
// Regression test for a bug which caused the timeline entries to be out-of-order
// when the same room appears twice with different timeline limits. E.g appears in
// the list with timeline_limit:1 then appears again as a room subscription with
@@ -394,6 +441,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body)));
logger.log("got:", gotRoom.getLiveTimeline().getEvents().map(
+12 -1
View File
@@ -6,7 +6,7 @@ import '../olm-loader';
import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -371,3 +371,14 @@ export async function awaitDecryption(event: MatrixEvent): Promise<MatrixEvent>
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise(r => e.once(k, r));
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
app_display_name: "app",
app_id: "123",
data: {},
device_display_name: "name",
kind: "http",
lang: "en",
pushkey: "pushpush",
...extra,
});
+5
View File
@@ -21,18 +21,21 @@ describe("NamespacedValue", () => {
const ns = new NamespacedValue("stable", "unstable");
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBe(ns.unstable);
expect(ns.names).toEqual([ns.stable, ns.unstable]);
});
it("should return unstable if there is no stable", () => {
const ns = new NamespacedValue(null, "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.unstable]);
});
it("should have a falsey unstable if needed", () => {
const ns = new NamespacedValue("stable", null);
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.stable]);
});
it("should match against either stable or unstable", () => {
@@ -58,12 +61,14 @@ describe("UnstableValue", () => {
const ns = new UnstableValue("stable", "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBe(ns.stable);
expect(ns.names).toEqual([ns.unstable, ns.stable]);
});
it("should return unstable if there is no stable", () => {
const ns = new UnstableValue(null, "unstable");
expect(ns.name).toBe(ns.unstable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.unstable]);
});
it("should not permit falsey unstable values", () => {
+61
View File
@@ -22,6 +22,7 @@ import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseBeaconContent,
parseTopicContent,
} from "../../src/content-helpers";
@@ -127,6 +128,66 @@ describe('Beacon content helpers', () => {
});
});
});
describe("parseBeaconContent()", () => {
it("should not explode when parsing an invalid beacon", () => {
// deliberate cast to simulate wire content being invalid
const result = parseBeaconContent({} as any);
expect(result).toEqual({
description: undefined,
uri: undefined,
timestamp: undefined,
});
});
it("should parse unstable values", () => {
const uri = "urigoeshere";
const description = "descriptiongoeshere";
const timestamp = 1234;
const result = parseBeaconContent({
"org.matrix.msc3488.location": {
uri,
description,
},
"org.matrix.msc3488.ts": timestamp,
// relationship not used - just here to satisfy types
"m.relates_to": {
rel_type: "m.reference",
event_id: "$unused",
},
});
expect(result).toEqual({
description,
uri,
timestamp,
});
});
it("should parse stable values", () => {
const uri = "urigoeshere";
const description = "descriptiongoeshere";
const timestamp = 1234;
const result = parseBeaconContent({
"m.location": {
uri,
description,
},
"m.ts": timestamp,
// relationship not used - just here to satisfy types
"m.relates_to": {
rel_type: "m.reference",
event_id: "$unused",
},
});
expect(result).toEqual({
description,
uri,
timestamp,
});
});
});
});
describe('Topic content helpers', () => {
+498 -12
View File
@@ -15,6 +15,8 @@ import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
import { MemoryStore } from "../../src";
import { RoomKeyRequestState } from '../../src/crypto/OutgoingRoomKeyRequestManager';
import { RoomMember } from '../../src/models/room-member';
import { IStore } from '../../src/store';
const Olm = global.Olm;
@@ -40,20 +42,52 @@ async function keyshareEventForEvent(client, event, index): Promise<MatrixEvent>
type: "m.forwarded_room_key",
sender: client.getUserId(),
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": roomId,
"sender_key": eventContent.sender_key,
"sender_claimed_ed25519_key": key.sender_claimed_ed25519_key,
"session_id": eventContent.session_id,
"session_key": key.key,
"chain_index": key.chain_index,
"forwarding_curve25519_key_chain": key.forwarding_curve_key_chain,
"org.matrix.msc3061.shared_history": true,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = "akey";
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = client.crypto.olmDevice.getOutboundGroupSessionKey(eventContent.session_id);
const ksEvent = new MatrixEvent({
type: "m.room_key",
sender: client.getUserId(),
content: {
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": roomId,
"session_id": eventContent.session_id,
"session_key": key.key,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = event.getSenderKey();
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
@@ -95,7 +129,7 @@ describe("Crypto", function() {
event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };};
event.getForwardingCurve25519KeyChain = () => ["not empty"];
event.isKeySourceUntrusted = () => false;
event.isKeySourceUntrusted = () => true;
event.getClaimedEd25519Key =
() => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
@@ -233,6 +267,7 @@ describe("Crypto", function() {
describe('Key requests', function() {
let aliceClient: MatrixClient;
let bobClient: MatrixClient;
let claraClient: MatrixClient;
beforeEach(async function() {
aliceClient = (new TestClient(
@@ -241,22 +276,35 @@ describe("Crypto", function() {
bobClient = (new TestClient(
"@bob:example.com", "bobdevice",
)).client;
claraClient = (new TestClient(
"@clara:example.com", "claradevice",
)).client;
await aliceClient.initCrypto();
await bobClient.initCrypto();
await claraClient.initCrypto();
});
afterEach(async function() {
aliceClient.stopClient();
bobClient.stopClient();
claraClient.stopClient();
});
it("does not cancel keyshare requests if some messages are not decrypted", async function() {
it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
// Make Bob invited by Alice so Bob will accept Alice's forwarded keys
bobRoom.currentState.setStateEvents([new MatrixEvent({
type: "m.room.member",
sender: "@alice:example.com",
room_id: roomId,
content: { membership: "invite" },
state_key: "@bob:example.com",
})]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
@@ -302,6 +350,9 @@ describe("Crypto", function() {
}
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -314,6 +365,8 @@ describe("Crypto", function() {
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
bobClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
@@ -340,8 +393,24 @@ describe("Crypto", function() {
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeTruthy();
await sleep(1);
// the room key request should be gone since we've now decrypted everything
// the room key request should still be there, since we've
// decrypted everything with an untrusted key
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// Now share a trusted room key event so Bob will re-decrypt the messages.
// Bob will backfill trust when they receive a trusted session with a higher
// index that connects to an untrusted session with a lower index.
const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]);
const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted");
await bobDecryptor.onRoomKeyEvent(roomKeyEvent);
await trustedDecryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeFalsy();
await sleep(1);
// now the room key request should be gone, since there's
// no better key to wait for
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy();
});
@@ -383,6 +452,9 @@ describe("Crypto", function() {
// decryption keys yet
}
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -462,6 +534,420 @@ describe("Crypto", function() {
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
it("should accept forwarded keys which it requested", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const cryptoStore = bobClient.crypto.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody);
expect(outgoingReq).toBeDefined();
await cryptoStore.updateOutgoingRoomKeyRequest(
outgoingReq.requestId, RoomKeyRequestState.Unsent,
{ state: RoomKeyRequestState.Sent },
);
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should accept forwarded keys from the user who invited it to the room", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
// Make Bob invited by Clara
bobRoom.currentState.setStateEvents([new MatrixEvent({
type: "m.room.member",
sender: "@clara:example.com",
room_id: roomId,
content: { membership: "invite" },
state_key: "@bob:example.com",
})]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@clara:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should accept forwarded keys from one of its own user's other devices", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = bobClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, bobClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should not accept unexpected forwarded keys for a room it's in", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).toBeNull();
});
it("should park forwarded keys for a room it's not in", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const content = events[0].getWireContent();
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const bobKey = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
expect(bobKey).toBeNull();
const aliceKey = await aliceClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
const parked = await bobClient.crypto.cryptoStore.takeParkedSharedHistory(roomId);
expect(parked).toEqual([{
senderId: aliceClient.getUserId(),
senderKey: content.sender_key,
sessionId: content.session_id,
sessionKey: aliceKey.key,
keysClaimed: { ed25519: aliceKey.sender_claimed_ed25519_key },
forwardingCurve25519KeyChain: ["akey"],
}]);
});
});
describe('Secret storage', function() {
@@ -110,6 +110,12 @@ describe("MegolmDecryption", function() {
senderCurve25519Key: "SENDER_CURVE25519",
claimedEd25519Key: "SENDER_ED25519",
};
event.getWireType = () => "m.room.encrypted";
event.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
const mockCrypto = {
decryptEvent: function() {
+6
View File
@@ -214,6 +214,12 @@ describe("MegolmBackup", function() {
const event = new MatrixEvent({
type: 'm.room.encrypted',
});
event.getWireType = () => "m.room.encrypted";
event.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
const decryptedData = {
clearEvent: {
type: 'm.room_key',
+10 -7
View File
@@ -26,6 +26,7 @@ import { logger } from '../../../src/logger';
import * as utils from "../../../src/utils";
import { ICreateClientOpts } from '../../../src/client';
import { ISecretStorageKeyInfo } from '../../../src/crypto/api';
import { DeviceInfo } from '../../../src/crypto/deviceinfo';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -250,20 +251,20 @@ describe("Secrets", function() {
osborne2.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
"VAX": {
user_id: "@alice:example.com",
device_id: "VAX",
known: false,
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:VAX": vaxDevice.deviceEd25519Key,
"curve25519:VAX": vaxDevice.deviceCurve25519Key,
},
verified: DeviceInfo.DeviceVerification.VERIFIED,
},
});
vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
"Osborne2": {
user_id: "@alice:example.com",
device_id: "Osborne2",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
verified: 0,
known: false,
keys: {
"ed25519:Osborne2": osborne2Device.deviceEd25519Key,
"curve25519:Osborne2": osborne2Device.deviceCurve25519Key,
@@ -280,10 +281,12 @@ describe("Secrets", function() {
Object.values(otks)[0],
);
const request = await secretStorage.request("foo", ["VAX"]);
const secret = await request.promise;
osborne2.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
osborne2.client.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const request = await secretStorage.request("foo", ["VAX"]);
await request.promise; // return value not used
expect(secret).toBe("bar");
osborne2.stop();
vax.stop();
clearTestClientTimeouts();
@@ -13,9 +13,9 @@ 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 { MatrixClient } from "../../../../src/client";
import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoomChannel";
import { MatrixEvent } from "../../../../src/models/event";
"../../../../src/crypto/verification/request/ToDeviceChannel";
describe("InRoomChannel tests", function() {
const ALICE = "@alice:hs.tld";
@@ -23,7 +23,7 @@ describe("InRoomChannel tests", function() {
const MALORY = "@malory:hs.tld";
const client = {
getUserId() { return ALICE; },
};
} as unknown as MatrixClient;
it("getEventType only returns .request for a message with a msgtype", function() {
const invalidEvent = new MatrixEvent({
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../../olm-loader";
import { verificationMethods } from "../../../../src/crypto";
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
import { logger } from "../../../../src/logger";
import { SAS } from "../../../../src/crypto/verification/SAS";
import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
@@ -52,23 +52,22 @@ describe("verification request integration tests with crypto layer", function()
alice.client.crypto.deviceList.getRawStoredDevicesForUser = function() {
return {
Dynabook: {
algorithms: [],
verified: 0,
known: false,
keys: {
"ed25519:Dynabook": "bob+base64+ed25519+key",
},
},
};
};
alice.client.downloadKeys = () => {
return Promise.resolve();
};
bob.client.downloadKeys = () => {
return Promise.resolve();
};
bob.client.on("crypto.verification.request", (request) => {
alice.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.on(CryptoEvent.VerificationRequest, (request) => {
const bobVerifier = request.beginKeyVerification(verificationMethods.SAS);
bobVerifier.verify();
// XXX: Private function access (but it's a test, so we're okay)
// @ts-ignore Private function access (but it's a test, so we're okay)
bobVerifier.endTimer();
});
const aliceRequest = await alice.client.requestVerification("@bob:example.com");
@@ -76,7 +75,7 @@ describe("verification request integration tests with crypto layer", function()
const aliceVerifier = aliceRequest.verifier;
expect(aliceVerifier).toBeInstanceOf(SAS);
// XXX: Private function access (but it's a test, so we're okay)
// @ts-ignore Private function access (but it's a test, so we're okay)
aliceVerifier.endTimer();
alice.stop();
@@ -19,10 +19,14 @@ import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
import { MatrixEvent } from "../../../../src/models/event";
import { SAS } from "../../../../src/crypto/verification/SAS";
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
import { verificationMethods } from "../../../../src/crypto";
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
import * as olmlib from "../../../../src/crypto/olmlib";
import { logger } from "../../../../src/logger";
import { resetCrossSigningKeys } from "../crypto-utils";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { MatrixClient } from "../../../../src";
import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
const Olm = global.Olm;
@@ -48,13 +52,15 @@ describe("SAS verification", function() {
//channel, baseApis, userId, deviceId, startEvent, request
const request = {
onVerifierCancelled: function() {},
};
} as VerificationRequest;
const channel = {
send: function() {
return Promise.resolve();
},
};
const sas = new SAS(channel, {}, "@alice:example.com", "ABCDEFG", null, request);
} as unknown as IVerificationChannel;
const mockClient = {} as unknown as MatrixClient;
const event = new MatrixEvent({ type: 'test' });
const sas = new SAS(channel, mockClient, "@alice:example.com", "ABCDEFG", event, request);
sas.handleEvent(new MatrixEvent({
sender: "@alice:example.com",
type: "es.inquisition",
@@ -65,7 +71,7 @@ describe("SAS verification", function() {
expect(spy).toHaveBeenCalled();
// Cancel the SAS for cleanup (we started a verification, so abort)
sas.cancel();
sas.cancel(new Error('error'));
});
describe("verification", () => {
@@ -403,16 +409,12 @@ describe("SAS verification", function() {
},
);
alice.client.setDeviceVerified = jest.fn();
alice.client.downloadKeys = () => {
return Promise.resolve();
};
alice.client.downloadKeys = jest.fn().mockResolvedValue({});
bob.client.setDeviceVerified = jest.fn();
bob.client.downloadKeys = () => {
return Promise.resolve();
};
bob.client.downloadKeys = jest.fn().mockResolvedValue({});
const bobPromise = new Promise((resolve, reject) => {
bob.client.on("crypto.verification.request", request => {
const bobPromise = new Promise<VerificationBase<any, any>>((resolve, reject) => {
bob.client.on(CryptoEvent.VerificationRequest, request => {
request.verifier.on("show_sas", (e) => {
e.mismatch();
});
@@ -421,7 +423,7 @@ describe("SAS verification", function() {
});
const aliceVerifier = alice.client.beginKeyVerification(
verificationMethods.SAS, bob.client.getUserId(), bob.client.deviceId,
verificationMethods.SAS, bob.client.getUserId()!, bob.client.deviceId!,
);
const aliceSpy = jest.fn();
@@ -462,7 +464,7 @@ describe("SAS verification", function() {
},
);
alice.client.setDeviceVerified = jest.fn();
alice.client.crypto.setDeviceVerification = jest.fn();
alice.client.getDeviceEd25519Key = () => {
return "alice+base64+ed25519+key";
};
@@ -480,7 +482,7 @@ describe("SAS verification", function() {
return Promise.resolve();
};
bob.client.setDeviceVerified = jest.fn();
bob.client.crypto.setDeviceVerification = jest.fn();
bob.client.getStoredDevice = () => {
return DeviceInfo.fromStorage(
{
@@ -501,7 +503,7 @@ describe("SAS verification", function() {
aliceSasEvent = null;
bobSasEvent = null;
bobPromise = new Promise((resolve, reject) => {
bobPromise = new Promise<void>((resolve, reject) => {
bob.client.on("crypto.verification.request", async (request) => {
const verifier = request.beginKeyVerification(SAS.NAME);
verifier.on("show_sas", (e) => {
@@ -563,10 +565,24 @@ describe("SAS verification", function() {
]);
// make sure Alice and Bob verified each other
expect(alice.client.setDeviceVerified)
.toHaveBeenCalledWith(bob.client.getUserId(), bob.client.deviceId);
expect(bob.client.setDeviceVerified)
.toHaveBeenCalledWith(alice.client.getUserId(), alice.client.deviceId);
expect(alice.client.crypto.setDeviceVerification)
.toHaveBeenCalledWith(
bob.client.getUserId(),
bob.client.deviceId,
true,
null,
null,
{ "ed25519:Dynabook": "bob+base64+ed25519+key" },
);
expect(bob.client.crypto.setDeviceVerification)
.toHaveBeenCalledWith(
alice.client.getUserId(),
alice.client.deviceId,
true,
null,
null,
{ "ed25519:Osborne2": "alice+base64+ed25519+key" },
);
});
});
});
@@ -18,6 +18,9 @@ import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning';
import { encodeBase64 } from "../../../../src/crypto/olmlib";
import { setupWebcrypto, teardownWebcrypto } from './util';
import { VerificationBase } from '../../../../src/crypto/verification/Base';
import { MatrixClient, MatrixEvent } from '../../../../src';
import { VerificationRequest } from '../../../../src/crypto/verification/request/VerificationRequest';
import { IVerificationChannel } from '../../../../src/crypto/verification/request/Channel';
jest.useFakeTimers();
@@ -54,9 +57,21 @@ describe("self-verifications", () => {
cacheCallbacks,
);
crossSigningInfo.keys = {
master: { keys: { X: testKeyPub } },
self_signing: { keys: { X: testKeyPub } },
user_signing: { keys: { X: testKeyPub } },
master: {
keys: { X: testKeyPub },
usage: [],
user_id: 'user-id',
},
self_signing: {
keys: { X: testKeyPub },
usage: [],
user_id: 'user-id',
},
user_signing: {
keys: { X: testKeyPub },
usage: [],
user_id: 'user-id',
},
};
const secretStorage = {
@@ -79,20 +94,22 @@ describe("self-verifications", () => {
getUserId: () => userId,
getKeyBackupVersion: () => Promise.resolve({}),
restoreKeyBackupWithCache,
};
} as unknown as MatrixClient;
const request = {
onVerifierFinished: () => undefined,
};
} as unknown as VerificationRequest;
const verification = new VerificationBase(
undefined, // channel
undefined as unknown as IVerificationChannel, // channel
client, // baseApis
userId,
"ABC", // deviceId
undefined, // startEvent
undefined as unknown as MatrixEvent, // startEvent
request,
);
// @ts-ignore set private property
verification.resolve = () => undefined;
const result = await verification.done();
@@ -102,12 +119,12 @@ describe("self-verifications", () => {
expect(secretStorage.request.mock.calls.length).toBe(4);
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1])
.toEqual(testKey);
.toEqual(testKey);
expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1])
.toEqual(testKey);
.toEqual(testKey);
expect(storeSessionBackupPrivateKey.mock.calls[0][0])
.toEqual(testKey);
.toEqual(testKey);
expect(restoreKeyBackupWithCache).toHaveBeenCalled();
@@ -19,20 +19,23 @@ import nodeCrypto from "crypto";
import { TestClient } from '../../../TestClient';
import { MatrixEvent } from "../../../../src/models/event";
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
import { Room, RoomEvent } from "../../../../src/models/room";
import { logger } from '../../../../src/logger';
import { MatrixClient, ClientEvent } from '../../../../src/client';
export async function makeTestClients(userInfos, options) {
const clients = [];
const timeouts = [];
const clientMap = {};
const sendToDevice = function(type, map) {
export async function makeTestClients(userInfos, options): Promise<[TestClient[], () => void]> {
const clients: TestClient[] = [];
const timeouts: ReturnType<typeof setTimeout>[] = [];
const clientMap: Record<string, Record<string, MatrixClient>> = {};
const makeSendToDevice = (matrixClient: MatrixClient): MatrixClient['sendToDevice'] => async (type, map) => {
// logger.log(this.getUserId(), "sends", type, map);
for (const [userId, devMap] of Object.entries(map)) {
if (userId in clientMap) {
for (const [deviceId, msg] of Object.entries(devMap)) {
if (deviceId in clientMap[userId]) {
const event = new MatrixEvent({
sender: this.getUserId(), // eslint-disable-line @babel/no-invalid-this
sender: matrixClient.getUserId()!,
type: type,
content: msg,
});
@@ -42,18 +45,19 @@ export async function makeTestClients(userInfos, options) {
Promise.resolve();
decryptionPromise.then(
() => client.emit("toDeviceEvent", event),
() => client.emit(ClientEvent.ToDeviceEvent, event),
);
}
}
}
}
return {};
};
const sendEvent = function(room, type, content) {
const makeSendEvent = (matrixClient: MatrixClient) => (room, type, content) => {
// make up a unique ID as the event ID
const eventId = "$" + this.makeTxnId(); // eslint-disable-line @babel/no-invalid-this
const eventId = "$" + matrixClient.makeTxnId();
const rawEvent = {
sender: this.getUserId(), // eslint-disable-line @babel/no-invalid-this
sender: matrixClient.getUserId()!,
type: type,
content: content,
room_id: room,
@@ -63,22 +67,24 @@ export async function makeTestClients(userInfos, options) {
const event = new MatrixEvent(rawEvent);
const remoteEcho = new MatrixEvent(Object.assign({}, rawEvent, {
unsigned: {
transaction_id: this.makeTxnId(), // eslint-disable-line @babel/no-invalid-this
transaction_id: matrixClient.makeTxnId(),
},
}));
const timeout = setTimeout(() => {
for (const tc of clients) {
if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this
const room = new Room('test', tc.client, tc.client.getUserId()!);
const roomTimelineData = {} as unknown as IRoomTimelineData;
if (tc.client === matrixClient) {
logger.log("sending remote echo!!");
tc.client.emit("Room.timeline", remoteEcho);
tc.client.emit(RoomEvent.Timeline, remoteEcho, room, false, false, roomTimelineData);
} else {
tc.client.emit("Room.timeline", event);
tc.client.emit(RoomEvent.Timeline, event, room, false, false, roomTimelineData);
}
}
});
timeouts.push(timeout);
timeouts.push(timeout as unknown as ReturnType<typeof setTimeout>);
return Promise.resolve({ event_id: eventId });
};
@@ -99,8 +105,8 @@ export async function makeTestClients(userInfos, options) {
clientMap[userInfo.userId] = {};
}
clientMap[userInfo.userId][userInfo.deviceId] = testClient.client;
testClient.client.sendToDevice = sendToDevice;
testClient.client.sendEvent = sendEvent;
testClient.client.sendToDevice = makeSendToDevice(testClient.client);
testClient.client.sendEvent = makeSendEvent(testClient.client);
clients.push(testClient);
}
@@ -116,11 +122,12 @@ export async function makeTestClients(userInfos, options) {
export function setupWebcrypto() {
global.crypto = {
getRandomValues: (buf) => {
return nodeCrypto.randomFillSync(buf);
return nodeCrypto.randomFillSync(buf as any);
},
};
} as unknown as Crypto;
}
export function teardownWebcrypto() {
// @ts-ignore undefined != Crypto
global.crypto = undefined;
}
@@ -19,11 +19,18 @@ import { InRoomChannel } from "../../../../src/crypto/verification/request/InRoo
import { ToDeviceChannel } from
"../../../../src/crypto/verification/request/ToDeviceChannel";
import { MatrixEvent } from "../../../../src/models/event";
import { MatrixClient } from "../../../../src/client";
import { setupWebcrypto, teardownWebcrypto } from "./util";
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
function makeMockClient(userId, deviceId) {
type MockClient = MatrixClient & {
popEvents: () => MatrixEvent[];
popDeviceEvents: (userId: string, deviceId: string) => MatrixEvent[];
};
function makeMockClient(userId: string, deviceId: string): MockClient {
let counter = 1;
let events = [];
let events: MatrixEvent[] = [];
const deviceEvents = {};
return {
getUserId() { return userId; },
@@ -54,16 +61,18 @@ function makeMockClient(userId, deviceId) {
deviceEvents[userId][deviceId].push(event);
}
}
return Promise.resolve();
return Promise.resolve({});
},
popEvents() {
// @ts-ignore special testing fn
popEvents(): MatrixEvent[] {
const e = events;
events = [];
return e;
},
popDeviceEvents(userId, deviceId) {
// @ts-ignore special testing fn
popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] {
const forDevice = deviceEvents[userId];
const events = forDevice && forDevice[deviceId];
const result = events || [];
@@ -72,12 +81,21 @@ function makeMockClient(userId, deviceId) {
}
return result;
},
};
} as unknown as MockClient;
}
const MOCK_METHOD = "mock-verify";
class MockVerifier {
constructor(channel, client, userId, deviceId, startEvent) {
class MockVerifier extends VerificationBase<'', any> {
public _channel;
public _startEvent;
constructor(
channel: IVerificationChannel,
client: MatrixClient,
userId: string,
deviceId: string,
startEvent: MatrixEvent,
) {
super(channel, client, userId, deviceId, startEvent, {} as unknown as VerificationRequest);
this._channel = channel;
this._startEvent = startEvent;
}
@@ -115,7 +133,10 @@ function makeRemoteEcho(event) {
async function distributeEvent(ownRequest, theirRequest, event) {
await ownRequest.channel.handleEvent(
makeRemoteEcho(event), ownRequest, true);
makeRemoteEcho(event),
ownRequest,
true,
);
await theirRequest.channel.handleEvent(event, theirRequest, true);
}
@@ -133,12 +154,19 @@ describe("verification request unit tests", function() {
it("transition from UNSENT to DONE through happy path", async function() {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const verificationMethods = new Map(
[[MOCK_METHOD, MockVerifier]],
) as unknown as Map<string, typeof VerificationBase>;
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()),
new Map([[MOCK_METHOD, MockVerifier]]), alice);
new InRoomChannel(alice, "!room", bob.getUserId()!),
verificationMethods,
alice,
);
const bobRequest = new VerificationRequest(
new InRoomChannel(bob, "!room"),
new Map([[MOCK_METHOD, MockVerifier]]), bob);
verificationMethods,
bob,
);
expect(aliceRequest.invalid).toBe(true);
expect(bobRequest.invalid).toBe(true);
@@ -157,7 +185,7 @@ describe("verification request unit tests", function() {
expect(aliceRequest.ready).toBe(true);
const verifier = aliceRequest.beginKeyVerification(MOCK_METHOD);
await verifier.start();
await (verifier as MockVerifier).start();
const [startEvent] = alice.popEvents();
expect(startEvent.getType()).toBe(START_TYPE);
await distributeEvent(aliceRequest, bobRequest, startEvent);
@@ -165,8 +193,7 @@ describe("verification request unit tests", function() {
expect(aliceRequest.verifier).toBeInstanceOf(MockVerifier);
expect(bobRequest.started).toBe(true);
expect(bobRequest.verifier).toBeInstanceOf(MockVerifier);
await bobRequest.verifier.start();
await (bobRequest.verifier as MockVerifier).start();
const [bobDoneEvent] = bob.popEvents();
expect(bobDoneEvent.getType()).toBe(DONE_TYPE);
await distributeEvent(bobRequest, aliceRequest, bobDoneEvent);
@@ -180,12 +207,20 @@ describe("verification request unit tests", function() {
it("methods only contains common methods", async function() {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceVerificationMethods = new Map(
[["c", function() {}], ["a", function() {}]],
) as unknown as Map<string, typeof VerificationBase>;
const bobVerificationMethods = new Map(
[["c", function() {}], ["b", function() {}]],
) as unknown as Map<string, typeof VerificationBase>;
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()),
new Map([["c", function() {}], ["a", function() {}]]), alice);
new InRoomChannel(alice, "!room", bob.getUserId()!),
aliceVerificationMethods, alice);
const bobRequest = new VerificationRequest(
new InRoomChannel(bob, "!room"),
new Map([["c", function() {}], ["b", function() {}]]), bob);
bobVerificationMethods,
bob,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
await distributeEvent(aliceRequest, bobRequest, requestEvent);
@@ -201,13 +236,22 @@ describe("verification request unit tests", function() {
const bob1 = makeMockClient("@bob:matrix.tld", "device1");
const bob2 = makeMockClient("@bob:matrix.tld", "device2");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob1.getUserId()), new Map(), alice);
new InRoomChannel(alice, "!room", bob1.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
const bob1Request = new VerificationRequest(
new InRoomChannel(bob1, "!room"), new Map(), bob1);
new InRoomChannel(bob1, "!room"),
new Map(),
bob1,
);
const bob2Request = new VerificationRequest(
new InRoomChannel(bob2, "!room"), new Map(), bob2);
new InRoomChannel(bob2, "!room"),
new Map(),
bob2,
);
await bob1Request.channel.handleEvent(requestEvent, bob1Request, true);
await bob2Request.channel.handleEvent(requestEvent, bob2Request, true);
@@ -222,22 +266,34 @@ describe("verification request unit tests", function() {
it("verify own device with to_device messages", async function() {
const bob1 = makeMockClient("@bob:matrix.tld", "device1");
const bob2 = makeMockClient("@bob:matrix.tld", "device2");
const verificationMethods = new Map(
[[MOCK_METHOD, MockVerifier]],
) as unknown as Map<string, typeof VerificationBase>;
const bob1Request = new VerificationRequest(
new ToDeviceChannel(bob1, bob1.getUserId(), ["device1", "device2"],
ToDeviceChannel.makeTransactionId(), "device2"),
new Map([[MOCK_METHOD, MockVerifier]]), bob1);
new ToDeviceChannel(
bob1,
bob1.getUserId()!,
["device1", "device2"],
ToDeviceChannel.makeTransactionId(),
"device2",
),
verificationMethods,
bob1,
);
const to = { userId: "@bob:matrix.tld", deviceId: "device2" };
const verifier = bob1Request.beginKeyVerification(MOCK_METHOD, to);
expect(verifier).toBeInstanceOf(MockVerifier);
await verifier.start();
await (verifier as MockVerifier).start();
const [startEvent] = bob1.popDeviceEvents(to.userId, to.deviceId);
expect(startEvent.getType()).toBe(START_TYPE);
const bob2Request = new VerificationRequest(
new ToDeviceChannel(bob2, bob2.getUserId(), ["device1"]),
new Map([[MOCK_METHOD, MockVerifier]]), bob2);
new ToDeviceChannel(bob2, bob2.getUserId()!, ["device1"]),
verificationMethods,
bob2,
);
await bob2Request.channel.handleEvent(startEvent, bob2Request, true);
await bob2Request.verifier.start();
await (bob2Request.verifier as MockVerifier).start();
const [doneEvent1] = bob2.popDeviceEvents("@bob:matrix.tld", "device1");
expect(doneEvent1.getType()).toBe(DONE_TYPE);
await bob1Request.channel.handleEvent(doneEvent1, bob1Request, true);
@@ -253,11 +309,13 @@ describe("verification request unit tests", function() {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice);
new InRoomChannel(alice, "!room", bob.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true,
true, true);
await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true);
expect(aliceRequest.cancelled).toBe(false);
expect(aliceRequest._cancellingUserId).toBe(undefined);
@@ -269,11 +327,17 @@ describe("verification request unit tests", function() {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
const aliceRequest = new VerificationRequest(
new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice);
new InRoomChannel(alice, "!room", bob.getUserId()!),
new Map(),
alice,
);
await aliceRequest.sendRequest();
const [requestEvent] = alice.popEvents();
const bobRequest = new VerificationRequest(
new InRoomChannel(bob, "!room"), new Map(), bob);
new InRoomChannel(bob, "!room"),
new Map(),
bob,
);
await bobRequest.channel.handleEvent(requestEvent, bobRequest, true);
+32 -1
View File
@@ -16,14 +16,15 @@ limitations under the License.
import * as utils from "../test-utils/test-utils";
import {
DuplicateStrategy,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
DuplicateStrategy,
} from '../../src';
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
@@ -291,4 +292,34 @@ describe('EventTimelineSet', () => {
expect(eventTimelineSet.canContain(event)).toBeTruthy();
});
});
describe("handleRemoteEcho", () => {
it("should add to liveTimeline only if the event matches the filter", () => {
const filter = new Filter(client.getUserId()!, "test_filter");
filter.setDefinition({
room: {
timeline: {
types: [EventType.RoomMessage],
},
},
});
const eventTimelineSet = new EventTimelineSet(room, { filter }, client);
const roomMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: { body: "test" },
event_id: "!test1:server",
});
eventTimelineSet.handleRemoteEcho(roomMessageEvent, "~!local-event-id:server", roomMessageEvent.getId());
expect(eventTimelineSet.getLiveTimeline().getEvents()).toContain(roomMessageEvent);
const roomFilteredEvent = new MatrixEvent({
type: "other_event_type",
content: { body: "test" },
event_id: "!test2:server",
});
eventTimelineSet.handleRemoteEcho(roomFilteredEvent, "~!local-event-id:server", roomFilteredEvent.getId());
expect(eventTimelineSet.getLiveTimeline().getEvents()).not.toContain(roomFilteredEvent);
});
});
});
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2022 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 { LocalNotificationSettings } from "../../src/@types/local_notifications";
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixClient } from "../../src/matrix";
import { TestClient } from '../TestClient';
let client: MatrixClient;
describe("Local notification settings", () => {
beforeEach(() => {
client = (new TestClient(
"@alice:matrix.org", "123", undefined, undefined, undefined,
)).client;
client.setAccountData = jest.fn();
});
describe("Lets you set local notification settings", () => {
it("stores settings in account data", () => {
const deviceId = "device";
const settings: LocalNotificationSettings = { is_silenced: true };
client.setLocalNotificationSettings(deviceId, settings);
expect(client.setAccountData).toHaveBeenCalledWith(
`${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`,
settings,
);
});
});
});
+35
View File
@@ -1,3 +1,4 @@
import { SSOAction } from '../../src/@types/auth';
import { TestClient } from '../TestClient';
describe('Login request', function() {
@@ -22,3 +23,37 @@ describe('Login request', function() {
expect(client.client.getUserId()).toBe(response.user_id);
});
});
describe('SSO login URL', function() {
let client: TestClient;
beforeEach(function() {
client = new TestClient();
});
afterEach(function() {
client.stop();
});
describe('SSOAction', function() {
const redirectUri = "https://test.com/foo";
it('No action', function() {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, undefined);
const url = new URL(urlString);
expect(url.searchParams.has('org.matrix.msc3824.action')).toBe(false);
});
it('register', function() {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.REGISTER);
const url = new URL(urlString);
expect(url.searchParams.get('org.matrix.msc3824.action')).toEqual('register');
});
it('login', function() {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.LOGIN);
const url = new URL(urlString);
expect(url.searchParams.get('org.matrix.msc3824.action')).toEqual('login');
});
});
});
+303 -1
View File
@@ -36,9 +36,14 @@ import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { ContentHelpers, Room } from "../../src";
import { ContentHelpers, EventTimeline, Room } from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
import {
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
POLICIES_ACCOUNT_EVENT_TYPE,
PolicyScope,
} from "../../src/models/invites-ignorer";
jest.useFakeTimers();
@@ -1412,4 +1417,301 @@ describe("MatrixClient", function() {
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
});
});
describe("support for ignoring invites", () => {
beforeEach(() => {
// Mockup `getAccountData`/`setAccountData`.
const dataStore = new Map();
client.setAccountData = function(eventType, content) {
dataStore.set(eventType, content);
return Promise.resolve();
};
client.getAccountData = function(eventType) {
const data = dataStore.get(eventType);
return new MatrixEvent({
content: data,
});
};
// Mockup `createRoom`/`getRoom`/`joinRoom`, including state.
const rooms = new Map();
client.createRoom = function(options = {}) {
const roomId = options["_roomId"] || `!room-${rooms.size}:example.org`;
const state = new Map();
const room = {
roomId,
_options: options,
_state: state,
getUnfilteredTimelineSet: function() {
return {
getLiveTimeline: function() {
return {
getState: function(direction) {
expect(direction).toBe(EventTimeline.FORWARDS);
return {
getStateEvents: function(type) {
const store = state.get(type) || {};
return Object.keys(store).map(key => store[key]);
},
};
},
};
},
};
},
};
rooms.set(roomId, room);
return Promise.resolve({ room_id: roomId });
};
client.getRoom = function(roomId) {
return rooms.get(roomId);
};
client.joinRoom = function(roomId) {
return this.getRoom(roomId) || this.createRoom({ _roomId: roomId });
};
// Mockup state events
client.sendStateEvent = function(roomId, type, content) {
const room = this.getRoom(roomId);
const state: Map<string, any> = room._state;
let store = state.get(type);
if (!store) {
store = {};
state.set(type, store);
}
const eventId = `$event-${Math.random()}:example.org`;
store[eventId] = {
getId: function() {
return eventId;
},
getRoomId: function() {
return roomId;
},
getContent: function() {
return content;
},
};
return { event_id: eventId };
};
client.redactEvent = function(roomId, eventId) {
const room = this.getRoom(roomId);
const state: Map<string, any> = room._state;
for (const store of state.values()) {
delete store[eventId];
}
};
});
it("should initialize and return the same `target` consistently", async () => {
const target1 = await client.ignoredInvites.getOrCreateTargetRoom();
const target2 = await client.ignoredInvites.getOrCreateTargetRoom();
expect(target1).toBeTruthy();
expect(target1).toBe(target2);
});
it("should initialize and return the same `sources` consistently", async () => {
const sources1 = await client.ignoredInvites.getOrCreateSourceRooms();
const sources2 = await client.ignoredInvites.getOrCreateSourceRooms();
expect(sources1).toBeTruthy();
expect(sources1).toHaveLength(1);
expect(sources1).toEqual(sources2);
});
it("should initially not reject any invite", async () => {
const rule = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(rule).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: user)", async () => {
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleWrongServerRoom).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: server)", async () => {
const REASON = `Just a test ${Math.random()}`;
await client.ignoredInvites.addRule(PolicyScope.Server, "example.org", REASON);
// We should reject these invites.
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleSenderMatch).toBeTruthy();
expect(ruleSenderMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
const ruleRoomMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleRoomMatch).toBeTruthy();
expect(ruleRoomMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: room)", async () => {
const REASON = `Just a test ${Math.random()}`;
const BAD_ROOM_ID = "!bad:example.org";
const GOOD_ROOM_ID = "!good:example.org";
await client.ignoredInvites.addRule(PolicyScope.Room, BAD_ROOM_ID, REASON);
// We should reject this invite.
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: BAD_ROOM_ID,
});
expect(ruleSenderMatch).toBeTruthy();
expect(ruleSenderMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
// We should let these invites go through.
const ruleWrongRoom = await client.ignoredInvites.getRuleForInvite({
sender: BAD_ROOM_ID,
roomId: GOOD_ROOM_ID,
});
expect(ruleWrongRoom).toBeFalsy();
});
it("should reject invites once we have added a matching rule in a non-target source room", async () => {
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
// Make sure that everything is initialized.
await client.ignoredInvites.getOrCreateSourceRooms();
await client.joinRoom(NEW_SOURCE_ROOM_ID);
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
// Add a rule in the new source room.
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, {
entity: "*:example.org",
reason: "just a test",
recommendation: "m.ban",
});
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleWrongServerRoom).toBeFalsy();
});
it("should not reject invites anymore once we have removed a rule", async () => {
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// After removing the invite, we shouldn't reject it anymore.
await client.ignoredInvites.removeRule(ruleMatch);
const ruleMatch2 = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch2).toBeFalsy();
});
it("should add new rules in the target room, rather than any other source room", async () => {
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
// Make sure that everything is initialized.
await client.ignoredInvites.getOrCreateSourceRooms();
await client.joinRoom(NEW_SOURCE_ROOM_ID);
const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID);
// Fetch the list of sources and check that we do not have the new room yet.
const policies = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
expect(policies).toBeTruthy();
const ignoreInvites = policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
expect(ignoreInvites).toBeTruthy();
expect(ignoreInvites.sources).toBeTruthy();
expect(ignoreInvites.sources).not.toContain(NEW_SOURCE_ROOM_ID);
// Add a source.
const added = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
expect(added).toBe(true);
const added2 = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
expect(added2).toBe(false);
// Fetch the list of sources and check that we have added the new room.
const policies2 = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
expect(policies2).toBeTruthy();
const ignoreInvites2 = policies2[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
expect(ignoreInvites2).toBeTruthy();
expect(ignoreInvites2.sources).toBeTruthy();
expect(ignoreInvites2.sources).toContain(NEW_SOURCE_ROOM_ID);
// Add a rule.
const eventId = await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// Check where it shows up.
const targetRoomId = ignoreInvites2.target;
const targetRoom = client.getRoom(targetRoomId);
expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy();
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
});
});
});
+1 -1
View File
@@ -565,7 +565,7 @@ describe("MSC3089TreeSpace", () => {
rooms = {};
rooms[tree.roomId] = parentRoom;
(<any>tree).room = parentRoom; // override readonly
client.getRoom = (r) => rooms[r];
client.getRoom = (r) => rooms[r ?? ""];
clientSendStateFn = jest.fn()
.mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
+23
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { MatrixEvent } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import {
@@ -431,6 +433,27 @@ describe('Beacon', () => {
expect(emitSpy).not.toHaveBeenCalled();
});
it("should ignore invalid beacon events", () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
const ev = new MatrixEvent({
type: M_BEACON_INFO.name,
sender: userId,
room_id: roomId,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: beacon.beaconInfoId,
},
},
});
beacon.addLocations([ev]);
expect(beacon.latestLocationEvent).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
describe('when beacon is live with a start timestamp is in the future', () => {
it('ignores locations before the beacon start timestamp', () => {
const startTimestamp = now + 60000;
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright 2022 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 MockHttpBackend from 'matrix-mock-request';
import { IHttpOpts, MatrixClient, PUSHER_ENABLED } from "../../src/matrix";
import { mkPusher } from '../test-utils/test-utils';
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
let client: MatrixClient;
let httpBackend: MockHttpBackend;
describe("Pushers", () => {
beforeEach(() => {
httpBackend = new MockHttpBackend();
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as unknown as IHttpOpts["request"],
});
});
describe("supports remotely toggling push notifications", () => {
it("migration support when connecting to a legacy homeserver", async () => {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
unstable_features: {
"org.matrix.msc3881": false,
},
});
httpBackend.when("GET", "/pushers").respond(200, {
pushers: [
mkPusher(),
mkPusher({ [PUSHER_ENABLED.name]: true }),
mkPusher({ [PUSHER_ENABLED.name]: false }),
],
});
const promise = client.getPushers();
await httpBackend.flushAllExpected();
await flushPromises();
const response = await promise;
expect(response.pushers[0][PUSHER_ENABLED.name]).toBe(true);
expect(response.pushers[1][PUSHER_ENABLED.name]).toBe(true);
expect(response.pushers[2][PUSHER_ENABLED.name]).toBe(false);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
/*
Copyright 2022 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 MockHttpBackend from 'matrix-mock-request';
import { ReceiptType } from '../../src/@types/read_receipts';
import { MatrixClient } from "../../src/client";
import { IHttpOpts } from '../../src/http-api';
import { EventType } from '../../src/matrix';
import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt';
import { encodeUri } from '../../src/utils';
import * as utils from "../test-utils/test-utils";
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
let client: MatrixClient;
let httpBackend: MockHttpBackend;
const THREAD_ID = "$thread_event_id";
const ROOM_ID = "!123:matrix.org";
const threadEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a thread",
"m.relates_to": {
"event_id": THREAD_ID,
"m.in_reply_to": {
"event_id": THREAD_ID,
},
"rel_type": "m.thread",
},
},
});
const roomEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a room",
},
});
function mockServerSideSupport(client, hasServerSideSupport) {
const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature;
client.doesServerSupportUnstableFeature = (unstableFeature) => {
if (unstableFeature === "org.matrix.msc3771") {
return Promise.resolve(hasServerSideSupport);
} else {
return doesServerSupportUnstableFeature(unstableFeature);
}
};
}
describe("Read receipt", () => {
beforeEach(() => {
httpBackend = new MockHttpBackend();
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as unknown as IHttpOpts["request"],
});
client.isGuest = () => false;
});
describe("sendReceipt", () => {
it("sends a thread read receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toEqual(THREAD_ID);
}).respond(200, {});
mockServerSideSupport(client, true);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: roomEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
}).respond(200, {});
mockServerSideSupport(client, true);
client.sendReceipt(roomEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt when there's no server support", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toBeUndefined();
}).respond(200, {});
mockServerSideSupport(client, false);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
});
});
+50 -45
View File
@@ -32,13 +32,14 @@ import {
RoomEvent,
} from "../../src";
import { EventTimeline } from "../../src/models/event-timeline";
import { IWrappedReceipt, Room } from "../../src/models/room";
import { Room } from "../../src/models/room";
import { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts";
import { Thread, ThreadEvent } from "../../src/models/thread";
import { WrappedReceipt } from "../../src/models/read-receipt";
describe("Room", function() {
const roomId = "!foo:bar";
@@ -288,11 +289,11 @@ describe("Room", function() {
room.addLiveEvents(events);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[0]],
{ timelineWasEmpty: undefined },
{ timelineWasEmpty: false },
);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[1]],
{ timelineWasEmpty: undefined },
{ timelineWasEmpty: false },
);
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
@@ -426,6 +427,17 @@ describe("Room", function() {
// but without the event ID matching we will still have the local event in pending events
expect(room.getEventForTxnId(txnId)).toBeUndefined();
});
it("should correctly handle remote echoes from other devices", () => {
const remoteEvent = utils.mkMessage({
room: roomId, user: userA, event: true,
});
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
// add the remoteEvent
room.addLiveEvents([remoteEvent]);
expect(room.timeline.length).toEqual(1);
});
});
describe('addEphemeralEvents', () => {
@@ -1419,6 +1431,19 @@ describe("Room", function() {
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
});
});
describe("hasUserReadUpTo", function() {
it("should acknowledge if an event has been read", function() {
const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts),
]));
expect(room.hasUserReadEvent(userB, eventToAck.getId())).toEqual(true);
});
it("return false for an unknown event", function() {
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
});
});
});
describe("tags", function() {
@@ -2428,8 +2453,8 @@ describe("Room", function() {
const room = new Room(roomId, client, userA);
it("handles missing receipt type", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null;
};
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
@@ -2437,19 +2462,17 @@ describe("Room", function() {
describe("prefers newer receipt", () => {
it("should compare correctly using timelines", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
return { eventId: "eventId1" } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
return { eventId: "eventId2" } as WrappedReceipt;
}
return null;
};
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= 2; i++) {
room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => {
return (event1 === `eventId${i}`) ? 1 : -1;
} } as EventTimelineSet);
@@ -2460,20 +2483,18 @@ describe("Room", function() {
describe("correctly compares by timestamp", () => {
it("should correctly compare, if we have all receipts", () => {
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= 2; i++) {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 1 : 0 } } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2", data: { ts: i === 2 ? 1 : 0 } } as IWrappedReceipt;
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3", data: { ts: i === 3 ? 1 : 0 } } as IWrappedReceipt;
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
@@ -2484,13 +2505,11 @@ describe("Room", function() {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId1", data: { ts: 0 } } as IWrappedReceipt;
}
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt;
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
@@ -2505,39 +2524,25 @@ describe("Room", function() {
});
it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
return { eventId: "eventId1" } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
return { eventId: "eventId2" } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`);
});
it("should give precedence to org.matrix.msc2285.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
if (receiptType === ReceiptType.UnstableReadPrivate) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt;
}
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
});
it("should give precedence to m.read", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
return { eventId: "eventId3" } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`);
+54
View File
@@ -20,6 +20,7 @@ import 'jest-localstorage-mock';
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { defer } from "../../../src/utils";
describe("IndexedDBStore", () => {
afterEach(() => {
@@ -111,4 +112,57 @@ describe("IndexedDBStore", () => {
await store.setPendingEvents(roomId, []);
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull();
});
it("should resolve isNewlyCreated to true if no database existed initially", async () => {
const store = new IndexedDBStore({
indexedDB,
dbName: "db1",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeTruthy();
});
it("should resolve isNewlyCreated to false if database existed already", async () => {
let store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
const deferred = defer<Event>();
// seed db3 to Version 1 so it forces a migration
const req = indexedDB.open("matrix-js-sdk:db3", 1);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("users", { keyPath: ["userId"] });
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = deferred.resolve;
await deferred.promise;
req.result.close();
const store = new IndexedDBStore({
indexedDB,
dbName: "db3",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
});
-6
View File
@@ -302,9 +302,6 @@ describe("SyncAccumulator", function() {
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
"some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" },
},
@@ -350,9 +347,6 @@ describe("SyncAccumulator", function() {
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
},
"$event2:localhost": {
[ReceiptType.Read]: {
-36
View File
@@ -528,38 +528,6 @@ describe("utils", function() {
});
});
describe('getPrivateReadReceiptField', () => {
it('should return m.read.private if server supports stable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285.stable";
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return m.read.private if server supports stable and unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return ["org.matrix.msc2285.stable", "org.matrix.msc2285"].includes(feature);
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return org.matrix.msc2285.read.private if server supports unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285";
}),
} as any)).toBe(ReceiptType.UnstableReadPrivate);
});
it('should return none if server does not support either', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
} as any)).toBeFalsy();
});
});
describe('isSupportedReceiptType', () => {
it('should support m.read', () => {
expect(utils.isSupportedReceiptType(ReceiptType.Read)).toBeTruthy();
@@ -569,10 +537,6 @@ describe("utils", function() {
expect(utils.isSupportedReceiptType(ReceiptType.ReadPrivate)).toBeTruthy();
});
it('should support org.matrix.msc2285.read.private', () => {
expect(utils.isSupportedReceiptType(ReceiptType.UnstableReadPrivate)).toBeTruthy();
});
it('should not support other receipt types', () => {
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
});
+5 -1
View File
@@ -156,9 +156,13 @@ export interface IPusher {
lang: string;
profile_tag?: string;
pushkey: string;
enabled?: boolean | null | undefined;
"org.matrix.msc3881.enabled"?: boolean | null | undefined;
device_id?: string | null;
"org.matrix.msc3881.device_id"?: string | null;
}
export interface IPusherRequest extends IPusher {
export interface IPusherRequest extends Omit<IPusher, "device_id" | "org.matrix.msc3881.device_id"> {
append?: boolean;
}
+88
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue";
// disable lint because these are wire responses
/* eslint-disable camelcase */
@@ -27,3 +29,89 @@ export interface IRefreshTokenResponse {
}
/* eslint-enable camelcase */
/**
* Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login
*/
export interface ILoginFlowsResponse {
flows: LoginFlow[];
}
export type LoginFlow = ISSOFlow | IPasswordFlow | ILoginFlow;
export interface ILoginFlow {
type: string;
}
export interface IPasswordFlow extends ILoginFlow {
type: "m.login.password";
}
export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
"delegated_oidc_compatibility",
"org.matrix.msc3824.delegated_oidc_compatibility",
);
/**
* Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso
*/
export interface ISSOFlow extends ILoginFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers?: IIdentityProvider[];
[DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
}
export enum IdentityProviderBrand {
Gitlab = "gitlab",
Github = "github",
Apple = "apple",
Google = "google",
Facebook = "facebook",
Twitter = "twitter",
}
export interface IIdentityProvider {
id: string;
name: string;
icon?: string;
brand?: IdentityProviderBrand | string;
}
/**
* Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
*/
/* eslint-disable camelcase */
export interface ILoginParams {
identifier?: object;
password?: string;
token?: string;
device_id?: string;
initial_device_display_name?: string;
}
/* eslint-enable camelcase */
export enum SSOAction {
/** The user intends to login to an existing account */
LOGIN = "login",
/** The user intends to register for a new account */
REGISTER = "register",
}
/**
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
* `m.login.token` issuance request.
* Note that this is UNSTABLE and subject to breaking changes without notice.
*/
export interface LoginTokenPostResponse {
/**
* The token to use with `m.login.token` to authenticate.
*/
login_token: string;
/**
* Expiration in seconds.
*/
expires_in: number;
}
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2022 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.
*/
export type OlmGroupSessionExtraData = {
untrusted?: boolean;
sharedHistory?: boolean;
};
+27
View File
@@ -191,6 +191,33 @@ export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue(
"m.visibility",
"org.matrix.msc3531.visibility");
/**
* https://github.com/matrix-org/matrix-doc/pull/3881
*
* @experimental
*/
export const PUSHER_ENABLED = new UnstableValue(
"enabled",
"org.matrix.msc3881.enabled");
/**
* https://github.com/matrix-org/matrix-doc/pull/3881
*
* @experimental
*/
export const PUSHER_DEVICE_ID = new UnstableValue(
"device_id",
"org.matrix.msc3881.device_id");
/**
* https://github.com/matrix-org/matrix-doc/pull/3890
*
* @experimental
*/
export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
"m.local_notification_settings",
"org.matrix.msc3890.local_notification_settings");
export interface IEncryptedFile {
url: string;
mimetype?: string;
+19
View File
@@ -0,0 +1,19 @@
/*
Copyright 2022 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.
*/
export interface LocalNotificationSettings {
is_silenced: boolean;
}
-4
View File
@@ -18,8 +18,4 @@ export enum ReceiptType {
Read = "m.read",
FullyRead = "m.fully_read",
ReadPrivate = "m.read.private",
/**
* @deprecated Please use the ReadPrivate type when possible. This value may be removed at any time without notice.
*/
UnstableReadPrivate = "org.matrix.msc2285.read.private",
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2022 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 { IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthData;
};
/**
* Helper type to represent HTTP response body for a UIA enabled endpoint
*/
export type UIAResponse<T> = T | IAuthData;
+11 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 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.
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk/lib/types";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
@@ -41,13 +43,20 @@ export class NamespacedValue<S extends string, U extends string> {
return this.unstable;
}
public get names(): (U | S)[] {
const names = [this.name];
const altName = this.altName;
if (altName) names.push(altName);
return names;
}
public matches(val: string): boolean {
return this.name === val || this.altName === val;
}
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): T {
public findIn<T>(obj: any): Optional<T> {
let val: T;
if (this.name) {
val = obj?.[this.name];
+144 -30
View File
@@ -158,7 +158,9 @@ import {
} from "./@types/requests";
import {
EventType,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MsgType,
PUSHER_ENABLED,
RelationType,
RoomCreateTypeField,
RoomType,
@@ -188,15 +190,20 @@ import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, Rule
import { IThreepid } from "./@types/threepids";
import { CryptoStore } from "./crypto/store/base";
import { MediaHandler } from "./webrtc/mediaHandler";
import { IRefreshTokenResponse } from "./@types/auth";
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ReceiptType } from "./@types/read_receipts";
import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { UnstableValue } from "./NamespacedValue";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { MAIN_ROOM_TIMELINE } from "./models/read-receipt";
import { IgnoredInvites } from "./models/invites-ignorer";
import { UIARequest, UIAResponse } from "./@types/uia";
import { LocalNotificationSettings } from "./@types/local_notifications";
export type Store = IStore;
@@ -521,10 +528,16 @@ interface IServerVersions {
unstable_features: Record<string, boolean>;
}
export const M_AUTHENTICATION = new UnstableValue(
"m.authentication",
"org.matrix.msc2965.authentication",
);
export interface IClientWellKnown {
[key: string]: any;
"m.homeserver"?: IWellKnownConfig;
"m.identity_server"?: IWellKnownConfig;
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
}
export interface IWellKnownConfig {
@@ -536,6 +549,13 @@ export interface IWellKnownConfig {
base_url?: string | null;
}
export interface IDelegatedAuthConfig { // MSC2965
/** The OIDC Provider/issuer the client should use */
issuer: string;
/** The optional URL of the web UI where the user can manage their account */
account?: string;
}
interface IKeyBackupPath {
path: string;
queryData?: {
@@ -880,6 +900,8 @@ export type ClientEventHandlerMap = {
& HttpApiEventHandlerMap
& BeaconEventHandlerMap;
const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action");
/**
* Represents a Matrix Client. Only directly construct this if you want to use
* custom modules. Normally, {@link createClient} should be used
@@ -901,7 +923,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
public identityServer: IIdentityServerProvider;
public http: MatrixHttpApi; // XXX: Intended private, used in code.
public crypto: Crypto; // XXX: Intended private, used in code.
public crypto?: Crypto; // XXX: Intended private, used in code.
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler: CallEventHandler; // XXX: Intended private, used in code.
public supportsCallTransfer = false; // XXX: Intended private, used in code.
@@ -954,6 +976,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private toDeviceMessageQueue: ToDeviceMessageQueue;
// A manager for determining which invites should be ignored.
public readonly ignoredInvites: IgnoredInvites;
constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1135,6 +1160,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
}
});
this.ignoredInvites = new IgnoredInvites(this);
}
/**
@@ -3323,7 +3350,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} roomId The room ID
* @return {Room|null} The Room or null if it doesn't exist or there is no data store.
*/
public getRoom(roomId: string): Room | null {
public getRoom(roomId: string | undefined): Room | null {
if (!roomId) {
return null;
}
return this.store.getRoom(roomId);
}
@@ -3412,7 +3442,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} eventType The event type
* @return {?object} The contents of the given account data event
*/
public getAccountData(eventType: string): MatrixEvent {
public getAccountData(eventType: string): MatrixEvent | undefined {
return this.store.getAccountData(eventType);
}
@@ -4581,7 +4611,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, callback?: Callback): Promise<{}> {
public async sendReceipt(
event: MatrixEvent,
receiptType: ReceiptType,
body: any,
callback?: Callback,
): Promise<{}> {
if (typeof (body) === 'function') {
callback = body as any as Callback; // legacy
body = {};
@@ -4596,10 +4631,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$receiptType: receiptType,
$eventId: event.getId(),
});
// TODO: Add a check for which spec version this will be released in
if (await this.doesServerSupportUnstableFeature("org.matrix.msc3771")) {
const isThread = !!event.threadRootId;
body.thread_id = isThread
? event.threadRootId
: MAIN_ROOM_TIMELINE;
}
const promise = this.http.authedRequest(callback, Method.Post, path, undefined, body || {});
const room = this.getRoom(event.getRoomId());
if (room) {
if (room && this.credentials.userId) {
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
}
return promise;
@@ -4613,7 +4657,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> {
public async sendReadReceipt(
event: MatrixEvent | null,
receiptType = ReceiptType.Read,
callback?: Callback,
): Promise<{} | undefined> {
if (!event) return;
const eventId = event.getId();
const room = this.getRoom(event.getRoomId());
if (room && room.hasPendingEvent(eventId)) {
@@ -5278,6 +5327,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {object} [options]
* @param {boolean} options.preventReEmit don't re-emit events emitted on an event mapped by this mapper on the client
* @param {boolean} options.decrypt decrypt event proactively
* @param {boolean} options.toDevice the event is a to_device event
* @return {Function}
*/
public getEventMapper(options?: MapperOpts): EventMapper {
@@ -5359,7 +5409,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
};
await thread.fetchInitialEvents();
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
let nextBatch: string | null | undefined = thread.liveTimeline.getPaginationToken(Direction.Backward);
// Fetch events until we find the one we were asked for, or we run out of pages
while (!thread.findEventById(eventId)) {
@@ -5599,13 +5649,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.processBeaconEvents(timelineSet.room, timelineEvents);
this.processThreadEvents(room, threadedEvents, backwards);
const atEnd = res.end === undefined || res.end === res.start;
// if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure
// we can recover from gappy syncs.
if (backwards && res.end == res.start) {
if (backwards && atEnd) {
eventTimeline.setPaginationToken(null, dir);
}
return res.end != res.start;
return !atEnd;
}).finally(() => {
eventTimeline.paginationRequests[dir] = null;
});
@@ -7071,10 +7123,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise<ILoginFlowsResponse>} Resolves to the available login flows
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public loginFlows(callback?: Callback): Promise<any> { // TODO: Types
public loginFlows(callback?: Callback): Promise<ILoginFlowsResponse> {
return this.http.request(callback, Method.Get, "/login");
}
@@ -7150,15 +7202,26 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} loginType The type of SSO login we are doing (sso or cas).
* Defaults to 'sso'.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
* @param {SSOAction} action the SSO flow to indicate to the IdP, optional.
* @return {string} The HS URL to hit to begin the SSO login process.
*/
public getSsoLoginUrl(redirectUrl: string, loginType = "sso", idpId?: string): string {
public getSsoLoginUrl(
redirectUrl: string,
loginType = "sso",
idpId?: string,
action?: SSOAction,
): string {
let url = "/login/" + loginType + "/redirect";
if (idpId) {
url += "/" + idpId;
}
return this.http.getUrl(url, { redirectUrl }, PREFIX_R0);
const params = {
redirectUrl,
[SSO_ACTION_PARAM.unstable!]: action,
};
return this.http.getUrl(url, params, PREFIX_R0);
}
/**
@@ -7232,6 +7295,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(undefined, Method.Post, '/account/deactivate', undefined, body);
}
/**
* Make a request for an `m.login.token` to be issued as per
* [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882).
* The server may require User-Interactive auth.
* Note that this is UNSTABLE and subject to breaking changes without notice.
* @param {IAuthData} auth Optional. Auth data to supply for User-Interactive auth.
* @return {Promise<UIAResponse<LoginTokenPostResponse>>} Resolves: On success, the token response
* or UIA auth data.
*/
public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
const body: UIARequest<{}> = { auth };
return this.http.authedRequest(
undefined, // no callback support
Method.Post,
"/org.matrix.msc3882/login/token",
undefined, // no query params
body,
{ prefix: PREFIX_UNSTABLE },
);
}
/**
* Get the fallback URL to use for unknown interactive-auth stages.
*
@@ -7326,7 +7410,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventType: eventType,
});
return this.http.authedRequest(
undefined, Method.Get, path, null, null, {
undefined, Method.Get, path, undefined, undefined, {
prefix: PREFIX_UNSTABLE,
},
);
@@ -7523,9 +7607,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
[ReceiptType.Read]: rrEventId,
};
const privateField = await utils.getPrivateReadReceiptField(this);
if (privateField) {
content[privateField] = rpEventId;
if (
(await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable"))
|| (await this.isVersionSupported("v1.4"))
) {
content[ReceiptType.ReadPrivate] = rpEventId;
}
return this.http.authedRequest(undefined, Method.Post, path, undefined, content);
@@ -7632,7 +7718,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
const prefix = PREFIX_V3;
return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix });
return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix });
}
/**
@@ -7869,7 +7955,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
'bind': bind,
};
return this.http.authedRequest(
callback, Method.Post, path, null, data,
callback, Method.Post, path, undefined, data,
);
}
@@ -7888,7 +7974,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
const path = "/account/3pid/add";
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix });
return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix });
}
/**
@@ -7910,7 +7996,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const prefix = await this.isVersionSupported("r0.6.0") ?
PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(
undefined, Method.Post, path, null, data, { prefix },
undefined, Method.Post, path, undefined, data, { prefix },
);
}
@@ -7937,7 +8023,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
id_server: this.getIdentityServerUrl(true),
};
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix });
return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix });
}
/**
@@ -7954,7 +8040,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// eslint-disable-next-line camelcase
): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
const path = "/account/3pid/delete";
return this.http.authedRequest(undefined, Method.Post, path, null, { medium, address });
return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address });
}
/**
@@ -8000,7 +8086,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
};
return this.http.authedRequest<{}>(
callback, Method.Post, path, null, data,
callback, Method.Post, path, undefined, data,
);
}
@@ -8091,8 +8177,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: Array of objects representing pushers
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
return this.http.authedRequest(callback, Method.Get, "/pushers");
public async getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
const response = await this.http.authedRequest(callback, Method.Get, "/pushers");
// Migration path for clients that connect to a homeserver that does not support
// MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration
if (!await this.doesServerSupportUnstableFeature("org.matrix.msc3881")) {
response.pushers = response.pushers.map(pusher => {
if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) {
pusher[PUSHER_ENABLED.name] = true;
}
return pusher;
});
}
return response;
}
/**
@@ -8105,7 +8204,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setPusher(pusher: IPusherRequest, callback?: Callback): Promise<{}> {
const path = "/pushers/set";
return this.http.authedRequest(callback, Method.Post, path, null, pusher);
return this.http.authedRequest(callback, Method.Post, path, undefined, pusher);
}
/**
* Persists local notification settings
* @param {string} deviceId
* @param {LocalNotificationSettings} notificationSettings
* @return {Promise} Resolves: an empty object
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public setLocalNotificationSettings(
deviceId: string,
notificationSettings: LocalNotificationSettings,
): Promise<{}> {
const key = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
return this.setAccountData(key, notificationSettings);
}
/**
@@ -8892,7 +9006,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: eventId,
});
return this.http.authedRequest(undefined, Method.Post, path, null, { score, reason });
return this.http.authedRequest(undefined, Method.Post, path, undefined, { score, reason });
}
/**
@@ -9054,7 +9168,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return this.http.authedRequest(undefined, Method.Get, path, { via }, null, {
return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, {
qsStringifyOptions: { arrayFormat: 'repeat' },
prefix: "/_matrix/client/unstable/im.nheko.summary",
});
+5 -4
View File
@@ -292,16 +292,17 @@ export const makeBeaconContent: MakeBeaconContent = (
});
export type BeaconLocationState = MLocationContent & {
timestamp: number;
uri?: string; // override from MLocationContent to allow optionals
timestamp?: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
const location = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
return {
description,
uri,
description: location?.description,
uri: location?.uri,
timestamp,
};
};
+51 -14
View File
@@ -23,6 +23,7 @@ import * as algorithms from './algorithms';
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
import { IMegolmSessionData } from "./index";
import { OlmGroupSessionExtraData } from "../@types/crypto";
// The maximum size of an event is 65K, and we base64 the content, so this is a
// reasonable approximation to the biggest plaintext we can encrypt.
@@ -122,6 +123,7 @@ interface IInboundGroupSessionKey {
forwarding_curve25519_key_chain: string[];
sender_claimed_ed25519_key: string;
shared_history: boolean;
untrusted: boolean;
}
/* eslint-enable camelcase */
@@ -1101,7 +1103,7 @@ export class OlmDevice {
sessionKey: string,
keysClaimed: Record<string, string>,
exportFormat: boolean,
extraSessionData: Record<string, any> = {},
extraSessionData: OlmGroupSessionExtraData = {},
): Promise<void> {
await this.cryptoStore.doTxn(
'readwrite', [
@@ -1133,17 +1135,42 @@ export class OlmDevice {
"Update for megolm session "
+ senderKey + "/" + sessionId,
);
if (existingSession.first_known_index()
<= session.first_known_index()
&& !(existingSession.first_known_index() == session.first_known_index()
&& !extraSessionData.untrusted
&& existingSessionData.untrusted)) {
// existing session has lower index (i.e. can
// decrypt more), or they have the same index and
// the new sessions trust does not win over the old
// sessions trust, so keep it
logger.log(`Keeping existing megolm session ${sessionId}`);
return;
if (existingSession.first_known_index() <= session.first_known_index()) {
if (!existingSessionData.untrusted || extraSessionData.untrusted) {
// existing session has less-than-or-equal index
// (i.e. can decrypt at least as much), and the
// new session's trust does not win over the old
// session's trust, so keep it
logger.log(`Keeping existing megolm session ${sessionId}`);
return;
}
if (existingSession.first_known_index() < session.first_known_index()) {
// We want to upgrade the existing session's trust,
// but we can't just use the new session because we'll
// lose the lower index. Check that the sessions connect
// properly, and then manually set the existing session
// as trusted.
if (
existingSession.export_session(session.first_known_index())
=== session.export_session(session.first_known_index())
) {
logger.info(
"Upgrading trust of existing megolm session " +
sessionId + " based on newly-received trusted session",
);
existingSessionData.untrusted = false;
this.cryptoStore.storeEndToEndInboundGroupSession(
senderKey, sessionId, existingSessionData, txn,
);
} else {
logger.warn(
"Newly-received megolm session " + sessionId +
" does not match existing session! Keeping existing session",
);
}
return;
}
// If the sessions have the same index, go ahead and store the new trusted one.
}
}
@@ -1427,13 +1454,23 @@ export class OlmDevice {
const claimedKeys = sessionData.keysClaimed || {};
const senderEd25519Key = claimedKeys.ed25519 || null;
const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || [];
// older forwarded keys didn't set the "untrusted"
// property, but can be identified by having a
// non-empty forwarding key chain. These keys should
// be marked as untrusted since we don't know that they
// can be trusted
const untrusted = "untrusted" in sessionData
? sessionData.untrusted
: forwardingKeyChain.length > 0;
result = {
"chain_index": chainIndex,
"key": exportedSession,
"forwarding_curve25519_key_chain":
sessionData.forwardingCurve25519KeyChain || [],
"forwarding_curve25519_key_chain": forwardingKeyChain,
"sender_claimed_ed25519_key": senderEd25519Key,
"shared_history": sessionData.sharedHistory || false,
"untrusted": untrusted,
};
},
);
+24
View File
@@ -539,7 +539,23 @@ export class SecretStorage {
// because someone could be trying to send us bogus data
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("secret event not properly encrypted");
return;
}
const content = event.getContent();
const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
event.getSenderKey() || "",
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
logger.log("got secret share for request", content.request_id);
const requestControl = this.requests.get(content.request_id);
if (requestControl) {
@@ -559,6 +575,14 @@ export class SecretStorage {
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
return;
}
// unsure that the sender is trusted. In theory, this check is
// unnecessary since we only accept secret shares from devices that
// we requested from, but it doesn't hurt.
const deviceTrust = this.baseApis.crypto.checkDeviceInfoTrust(event.getSender(), deviceInfo);
if (!deviceTrust.isVerified()) {
logger.log("secret share from unverified device");
return;
}
logger.log(
`Successfully received secret ${requestControl.name} ` +
+1 -1
View File
@@ -23,7 +23,7 @@ limitations under the License.
import { MatrixClient } from "../../client";
import { Room } from "../../models/room";
import { OlmDevice } from "../OlmDevice";
import { MatrixEvent, RoomMember } from "../..";
import { MatrixEvent, RoomMember } from "../../matrix";
import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo";
import { IRoomEncryption } from "../RoomList";
+114 -17
View File
@@ -35,8 +35,10 @@ import { Room } from '../../models/room';
import { DeviceInfo } from "../deviceinfo";
import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList";
import { MatrixEvent } from "../..";
import { MatrixEvent } from "../../models/event";
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager';
import { OlmGroupSessionExtraData } from "../../@types/crypto";
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@@ -1189,8 +1191,9 @@ class MegolmEncryption extends EncryptionAlgorithm {
* {@link module:crypto/algorithms/DecryptionAlgorithm}
*/
class MegolmDecryption extends DecryptionAlgorithm {
// events which we couldn't decrypt due to unknown sessions / indexes: map from
// senderKey|sessionId to Set of MatrixEvents
// events which we couldn't decrypt due to unknown sessions /
// indexes, or which we could only decrypt with untrusted keys:
// map from senderKey|sessionId to Set of MatrixEvents
private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>();
// this gets stubbed out by the unit tests.
@@ -1294,9 +1297,13 @@ class MegolmDecryption extends DecryptionAlgorithm {
);
}
// success. We can remove the event from the pending list, if that hasn't
// already happened.
this.removeEventFromPendingList(event);
// Success. We can remove the event from the pending list, if
// that hasn't already happened. However, if the event was
// decrypted with an untrusted key, leave it on the pending
// list so it will be retried if we find a trusted key later.
if (!res.untrusted) {
this.removeEventFromPendingList(event);
}
const payload = JSON.parse(res.result);
@@ -1391,6 +1398,8 @@ class MegolmDecryption extends DecryptionAlgorithm {
let exportFormat = false;
let keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>;
const extraSessionData: OlmGroupSessionExtraData = {};
if (!content.room_id ||
!content.session_key ||
!content.session_id ||
@@ -1400,12 +1409,59 @@ class MegolmDecryption extends DecryptionAlgorithm {
return;
}
if (!senderKey) {
logger.error("key event has no sender key (not encrypted?)");
if (!olmlib.isOlmEncrypted(event)) {
logger.error("key event not properly encrypted");
return;
}
if (content["org.matrix.msc3061.shared_history"]) {
extraSessionData.sharedHistory = true;
}
if (event.getType() == "m.forwarded_room_key") {
const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
senderKey,
);
const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
senderKey,
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
const outgoingRequests = deviceInfo ? await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget(
event.getSender(), deviceInfo.deviceId, [RoomKeyRequestState.Sent],
) : [];
const weRequested = outgoingRequests.some((req) => (
req.requestBody.room_id === content.room_id && req.requestBody.session_id === content.session_id
));
const room = this.baseApis.getRoom(content.room_id);
const memberEvent = room?.getMember(this.userId)?.events.member;
const fromInviter = memberEvent?.getSender() === event.getSender() ||
(memberEvent?.getUnsigned()?.prev_sender === event.getSender() &&
memberEvent?.getPrevContent()?.membership === "invite");
const fromUs = event.getSender() === this.baseApis.getUserId();
if (!weRequested && !fromUs) {
// If someone sends us an unsolicited key and they're
// not one of our other devices and it's not shared
// history, ignore it
if (!extraSessionData.sharedHistory) {
logger.log("forwarded key not shared history - ignoring");
return;
}
// If someone sends us an unsolicited key for a room
// we're already in, and they're not one of our other
// devices or the one who invited us, ignore it
if (room && !fromInviter) {
logger.log("forwarded key not from inviter or from us - ignoring");
return;
}
}
exportFormat = true;
forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ?
content.forwarding_curve25519_key_chain : [];
@@ -1418,7 +1474,6 @@ class MegolmDecryption extends DecryptionAlgorithm {
logger.error("forwarded_room_key event is missing sender_key field");
return;
}
senderKey = content.sender_key;
const ed25519Key = content.sender_claimed_ed25519_key;
if (!ed25519Key) {
@@ -1431,11 +1486,45 @@ class MegolmDecryption extends DecryptionAlgorithm {
keysClaimed = {
ed25519: ed25519Key,
};
// If this is a key for a room we're not in, don't load it
// yet, just park it in case *this sender* invites us to
// that room later
if (!room) {
const parkedData = {
senderId: event.getSender(),
senderKey: content.sender_key,
sessionId: content.session_id,
sessionKey: content.session_key,
keysClaimed,
forwardingCurve25519KeyChain: forwardingKeyChain,
};
await this.crypto.cryptoStore.doTxn(
'readwrite',
['parked_shared_history'],
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(content.room_id, parkedData, txn),
logger.withPrefix("[addParkedSharedHistory]"),
);
return;
}
const sendingDevice = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey);
const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender(), sendingDevice);
if (fromUs && !deviceTrust.isVerified()) {
return;
}
// forwarded keys are always untrusted
extraSessionData.untrusted = true;
// replace the sender key with the sender key of the session
// creator for storage
senderKey = content.sender_key;
} else {
keysClaimed = event.getKeysClaimed();
}
const extraSessionData: any = {};
if (content["org.matrix.msc3061.shared_history"]) {
extraSessionData.sharedHistory = true;
}
@@ -1453,7 +1542,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
);
// have another go at decrypting events sent with this session.
if (await this.retryDecryption(senderKey, content.session_id)) {
if (await this.retryDecryption(senderKey, content.session_id, !extraSessionData.untrusted)) {
// cancel any outstanding room key requests for this session.
// Only do this if we managed to decrypt every message in the
// session, because if we didn't, we leave the other key
@@ -1668,7 +1757,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
session: IMegolmSessionData,
opts: { untrusted?: boolean, source?: string } = {},
): Promise<void> {
const extraSessionData: any = {};
const extraSessionData: OlmGroupSessionExtraData = {};
if (opts.untrusted || session.untrusted) {
extraSessionData.untrusted = true;
}
@@ -1696,7 +1785,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
});
}
// have another go at decrypting events sent with this session.
this.retryDecryption(session.sender_key, session.session_id);
this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted);
});
}
@@ -1707,10 +1796,17 @@ class MegolmDecryption extends DecryptionAlgorithm {
* @private
* @param {String} senderKey
* @param {String} sessionId
* @param {Boolean} forceRedecryptIfUntrusted whether messages that were already
* successfully decrypted using untrusted keys should be re-decrypted
*
* @return {Boolean} whether all messages were successfully decrypted
* @return {Boolean} whether all messages were successfully
* decrypted with trusted keys
*/
private async retryDecryption(senderKey: string, sessionId: string): Promise<boolean> {
private async retryDecryption(
senderKey: string,
sessionId: string,
forceRedecryptIfUntrusted?: boolean,
): Promise<boolean> {
const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) {
return true;
@@ -1725,13 +1821,14 @@ class MegolmDecryption extends DecryptionAlgorithm {
await Promise.all([...pending].map(async (ev) => {
try {
await ev.attemptDecryption(this.crypto, { isRetry: true });
await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted });
} catch (e) {
// don't die if something goes wrong
}
}));
// If decrypted successfully, they'll have been removed from pendingEvents
// If decrypted successfully with trusted keys, they'll have
// been removed from pendingEvents
return !this.pendingEvents.get(senderKey)?.has(sessionId);
}
+21 -1
View File
@@ -30,7 +30,7 @@ import {
registerAlgorithm,
} from "./base";
import { Room } from '../../models/room';
import { MatrixEvent } from "../..";
import { MatrixEvent } from "../../models/event";
import { IEventDecryptionResult } from "../index";
import { IInboundSession } from "../OlmDevice";
@@ -222,6 +222,26 @@ class OlmDecryption extends DecryptionAlgorithm {
);
}
// check that the device that encrypted the event belongs to the user
// that the event claims it's from. We need to make sure that our
// device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out. Some event handlers, such as
// secret sharing, may be more strict and reject events that come from
// unknown devices.
await this.crypto.deviceList.downloadKeys([event.getSender()], false);
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
deviceKey,
);
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined) {
throw new DecryptionError(
"OLM_BAD_SENDER",
"Message claimed to be from " + event.getSender(), {
real_sender: senderKeyUser,
},
);
}
// check that the original sender matches what the homeserver told us, to
// avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key,
-1
View File
@@ -431,7 +431,6 @@ export class BackupManager {
)
);
});
ret.usable = ret.usable || ret.trusted_locally;
return ret;
}
+17 -9
View File
@@ -2105,6 +2105,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param {?boolean} known whether to mark that the user has been made aware of
* the existence of this device. Null to leave unchanged
*
* @param {?Record<string, any>} keys The list of keys that was present
* during the device verification. This will be double checked with the list
* of keys the given device has currently.
*
* @return {Promise<module:crypto/deviceinfo>} updated DeviceInfo
*/
public async setDeviceVerification(
@@ -2113,6 +2117,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
verified?: boolean,
blocked?: boolean,
known?: boolean,
keys?: Record<string, string>,
): Promise<DeviceInfo | CrossSigningInfo> {
// get rid of any `undefined`s here so we can just check
// for null rather than null or undefined
@@ -2131,6 +2136,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
if (!verified) {
throw new Error("Cannot set a cross-signing key as unverified");
}
const gotKeyId = keys ? Object.values(keys)[0] : null;
if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) {
throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`);
}
if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) {
this.storeTrustedSelfKeys(xsk.keys);
@@ -2191,6 +2200,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
let verificationStatus = dev.verified;
if (verified) {
if (keys) {
for (const [keyId, key] of Object.entries(keys)) {
if (dev.keys[keyId] !== key) {
throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`);
}
}
}
verificationStatus = DeviceVerification.VERIFIED;
} else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) {
verificationStatus = DeviceVerification.UNVERIFIED;
@@ -2400,13 +2416,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return null;
}
const forwardingChain = event.getForwardingCurve25519KeyChain();
if (forwardingChain.length > 0) {
// we got the key this event from somewhere else
// TODO: check if we can trust the forwarders.
return null;
}
if (event.isKeySourceUntrusted()) {
// we got the key for this event from a source that we consider untrusted
return null;
@@ -2478,8 +2487,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
ret.encrypted = true;
const forwardingChain = event.getForwardingCurve25519KeyChain();
if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) {
if (event.isKeySourceUntrusted()) {
// we got the key this event from somewhere else
// TODO: check if we can trust the forwarders.
ret.authenticated = false;
+18
View File
@@ -30,6 +30,8 @@ import { logger } from '../logger';
import { IOneTimeKey } from "./dehydration";
import { IClaimOTKsResult, MatrixClient } from "../client";
import { ISignatures } from "../@types/signed";
import { MatrixEvent } from "../models/event";
import { EventType } from "../@types/event";
enum Algorithm {
Olm = "m.olm.v1.curve25519-aes-sha2",
@@ -554,6 +556,22 @@ export function pkVerify(obj: IObject, pubKey: string, userId: string) {
}
}
/**
* Check that an event was encrypted using olm.
*/
export function isOlmEncrypted(event: MatrixEvent): boolean {
if (!event.getSenderKey()) {
logger.error("Event has no sender key (not encrypted?)");
return false;
}
if (event.getWireType() !== EventType.RoomMessageEncrypted ||
!(["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm))) {
logger.error("Event was not encrypted using an appropriate algorithm");
return false;
}
return true;
}
/**
* Encode a typed array of uint8 as base64.
* @param {Uint8Array} uint8Array The data to encode.
+12
View File
@@ -25,6 +25,7 @@ import { ICrossSigningInfo } from "../CrossSigning";
import { PrefixedLogger } from "../../logger";
import { InboundGroupSessionData } from "../OlmDevice";
import { IEncryptedPayload } from "../aes";
import { MatrixEvent } from "../../models/event";
/**
* Internal module. Definitions for storage for the crypto module
@@ -127,6 +128,8 @@ export interface CryptoStore {
roomId: string,
txn?: unknown,
): Promise<[senderKey: string, sessionId: string][]>;
addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
@@ -203,3 +206,12 @@ export interface OutgoingRoomKeyRequest {
requestBody: IRoomKeyRequestBody;
state: RoomKeyRequestState;
}
export interface ParkedSharedHistory {
senderId: string;
senderKey: string;
sessionId: string;
sessionKey: string;
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
forwardingCurve25519KeyChain: string[];
}
@@ -25,6 +25,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -33,7 +34,6 @@ import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { IEncryptedPayload } from "../aes";
export const VERSION = 10;
const PROFILE_TRANSACTIONS = false;
/**
@@ -873,6 +873,50 @@ export class Backend implements CryptoStore {
});
}
public addParkedSharedHistory(
roomId: string,
parkedData: ParkedSharedHistory,
txn?: IDBTransaction,
): void {
if (!txn) {
txn = this.db.transaction(
"parked_shared_history", "readwrite",
);
}
const objectStore = txn.objectStore("parked_shared_history");
const req = objectStore.get([roomId]);
req.onsuccess = () => {
const { parked } = req.result || { parked: [] };
parked.push(parkedData);
objectStore.put({ roomId, parked });
};
}
public takeParkedSharedHistory(
roomId: string,
txn?: IDBTransaction,
): Promise<ParkedSharedHistory[]> {
if (!txn) {
txn = this.db.transaction(
"parked_shared_history", "readwrite",
);
}
const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
return new Promise((resolve, reject) => {
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) {
resolve([]);
return;
}
const data = cursor.value;
cursor.delete();
resolve(data);
};
cursorReq.onerror = reject;
});
}
public doTxn<T>(
mode: Mode,
stores: string | string[],
@@ -905,45 +949,34 @@ export class Backend implements CryptoStore {
}
}
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(
`Upgrading IndexedDBCryptoStore from version ${oldVersion}`
+ ` to ${VERSION}`,
);
if (oldVersion < 1) { // The database did not previously exist.
createDatabase(db);
}
if (oldVersion < 2) {
db.createObjectStore("account");
}
if (oldVersion < 3) {
type DbMigration = (db: IDBDatabase) => void;
const DB_MIGRATIONS: DbMigration[] = [
(db) => { createDatabase(db); },
(db) => { db.createObjectStore("account"); },
(db) => {
const sessionsStore = db.createObjectStore("sessions", {
keyPath: ["deviceKey", "sessionId"],
});
sessionsStore.createIndex("deviceKey", "deviceKey");
}
if (oldVersion < 4) {
},
(db) => {
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) => { db.createObjectStore("device_data"); },
(db) => { db.createObjectStore("rooms"); },
(db) => {
db.createObjectStore("sessions_needing_backup", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
if (oldVersion < 8) {
},
(db) => {
db.createObjectStore("inbound_group_sessions_withheld", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
if (oldVersion < 9) {
},
(db) => {
const problemsStore = db.createObjectStore("session_problems", {
keyPath: ["deviceKey", "time"],
});
@@ -952,13 +985,29 @@ export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
db.createObjectStore("notified_error_devices", {
keyPath: ["userId", "deviceId"],
});
}
if (oldVersion < 10) {
},
(db) => {
db.createObjectStore("shared_history_inbound_group_sessions", {
keyPath: ["roomId"],
});
}
},
(db) => {
db.createObjectStore("parked_shared_history", {
keyPath: ["roomId"],
});
},
// Expand as needed.
];
export const VERSION = DB_MIGRATIONS.length;
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(
`Upgrading IndexedDBCryptoStore from version ${oldVersion}`
+ ` to ${VERSION}`,
);
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
}
function createDatabase(db: IDBDatabase): void {
@@ -29,6 +29,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -55,6 +56,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
public static STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions';
public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld';
public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = 'shared_history_inbound_group_sessions';
public static STORE_PARKED_SHARED_HISTORY = 'parked_shared_history';
public static STORE_DEVICE_DATA = 'device_data';
public static STORE_ROOMS = 'rooms';
public static STORE_BACKUP = 'sessions_needing_backup';
@@ -669,6 +671,27 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend.getSharedHistoryInboundGroupSessions(roomId, txn);
}
/**
* Park a shared-history group session for a room we may be invited to later.
*/
public addParkedSharedHistory(
roomId: string,
parkedData: ParkedSharedHistory,
txn?: IDBTransaction,
): void {
this.backend.addParkedSharedHistory(roomId, parkedData, txn);
}
/**
* Pop out all shared-history group sessions for a room.
*/
public takeParkedSharedHistory(
roomId: string,
txn?: IDBTransaction,
): Promise<ParkedSharedHistory[]> {
return this.backend.takeParkedSharedHistory(roomId, txn);
}
/**
* Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may
+14
View File
@@ -25,6 +25,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -58,6 +59,7 @@ export class MemoryCryptoStore implements CryptoStore {
private rooms: { [roomId: string]: IRoomEncryption } = {};
private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Ensure the database exists and is up-to-date.
@@ -526,6 +528,18 @@ export class MemoryCryptoStore implements CryptoStore {
return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
parked.push(parkedData);
this.parkedSharedHistory.set(roomId, parked);
}
public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
this.parkedSharedHistory.delete(roomId);
return Promise.resolve(parked);
}
// Session key backups
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {
+19 -6
View File
@@ -299,7 +299,13 @@ export class VerificationBase<
if (this.doVerification && !this.started) {
this.started = true;
this.resetTimer(); // restart the timeout
Promise.resolve(this.doVerification()).then(this.done.bind(this), this.cancel.bind(this));
new Promise<void>((resolve, reject) => {
const crossSignId = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(this.userId)?.getId();
if (crossSignId === this.deviceId) {
reject(new Error("Device ID is the same as the cross-signing ID"));
}
resolve();
}).then(() => this.doVerification()).then(this.done.bind(this), this.cancel.bind(this));
}
return this.promise;
}
@@ -310,14 +316,14 @@ export class VerificationBase<
// we try to verify all the keys that we're told about, but we might
// not know about all of them, so keep track of the keys that we know
// about, and ignore the rest
const verifiedDevices = [];
const verifiedDevices: [string, string, string][] = [];
for (const [keyId, keyInfo] of Object.entries(keys)) {
const deviceId = keyId.split(':', 2)[1];
const device = this.baseApis.getStoredDevice(userId, deviceId);
if (device) {
verifier(keyId, device, keyInfo);
verifiedDevices.push(deviceId);
verifiedDevices.push([deviceId, keyId, device.keys[keyId]]);
} else {
const crossSigningInfo = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(userId);
if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
@@ -326,7 +332,7 @@ export class VerificationBase<
[keyId]: deviceId,
},
}, deviceId), keyInfo);
verifiedDevices.push(deviceId);
verifiedDevices.push([deviceId, keyId, deviceId]);
} else {
logger.warn(
`verification: Could not find device ${deviceId} to verify`,
@@ -348,8 +354,15 @@ export class VerificationBase<
// TODO: There should probably be a batch version of this, otherwise it's going
// to upload each signature in a separate API call which is silly because the
// API supports as many signatures as you like.
for (const deviceId of verifiedDevices) {
await this.baseApis.setDeviceVerified(userId, deviceId);
for (const [deviceId, keyId, key] of verifiedDevices) {
await this.baseApis.crypto.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key });
}
// if one of the user's own devices is being marked as verified / unverified,
// check the key backup status, since whether or not we use this depends on
// whether it has a signature from a verified device
if (userId == this.baseApis.credentials.userId) {
await this.baseApis.checkKeyBackup();
}
}
+5
View File
@@ -22,6 +22,7 @@ export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent;
export interface MapperOpts {
preventReEmit?: boolean;
decrypt?: boolean;
toDevice?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
@@ -29,6 +30,10 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>) {
if (options.toDevice) {
delete plainOldJsObject.room_id;
}
const room = client.getRoom(plainOldJsObject.room_id);
let event: MatrixEvent;
+7 -7
View File
@@ -365,7 +365,7 @@ export class MatrixHttpApi {
// we're setting opts.json=false so that it doesn't JSON-encode the
// request, which also means it doesn't JSON-decode the response. Either
// way, we have to JSON-parse the response ourselves.
let bodyParser = null;
let bodyParser: ((body: string) => any) | undefined;
if (!rawResponse) {
bodyParser = function(rawBody: string) {
let body = JSON.parse(rawBody);
@@ -472,7 +472,7 @@ export class MatrixHttpApi {
headers["Content-Length"] = "0";
}
promise = this.authedRequest(
promise = this.authedRequest<UploadContentResponseType<O>>(
opts.callback, Method.Post, "/upload", queryParams, body, {
prefix: "/_matrix/media/r0",
headers,
@@ -590,10 +590,10 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
path: string,
queryParams?: Record<string, string | string[]>,
queryParams?: Record<string, string | string[] | undefined>,
data?: CoreOptions["body"],
opts?: O | number, // number is legacy
): IAbortablePromise<ResponseType<T, O>> {
@@ -667,7 +667,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public request<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
path: string,
queryParams?: CoreOptions["qs"],
@@ -711,7 +711,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public requestOtherUrl<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
uri: string,
queryParams?: CoreOptions["qs"],
@@ -778,7 +778,7 @@ export class MatrixHttpApi {
* Generic O should be inferred
*/
private doRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
uri: string,
queryParams?: Record<string, string>,
+1 -1
View File
@@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "./event";
import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
import { EventTimeline } from "./event-timeline";
import { FileType } from "../http-api";
import type { ISendEventResponse } from "..";
import type { ISendEventResponse } from "../@types/requests";
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
+3 -2
View File
@@ -15,7 +15,6 @@ limitations under the License.
*/
import { MBeaconEventContent } from "../@types/beacon";
import { M_TIMESTAMP } from "../@types/location";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix";
import { sortEventsByLatestContentTimestamp } from "../utils";
@@ -161,7 +160,9 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
const validLocationEvents = beaconLocationEvents.filter(event => {
const content = event.getContent<MBeaconEventContent>();
const timestamp = M_TIMESTAMP.findIn<number>(content);
const parsed = parseBeaconContent(content);
if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
const { timestamp } = parsed;
return (
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
+6 -13
View File
@@ -287,7 +287,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
public getTimelineForEvent(eventId: string): EventTimeline | null {
public getTimelineForEvent(eventId: string | null): EventTimeline | null {
if (eventId === null) { return null; }
const res = this._eventIdToTimeline.get(eventId);
return (res === undefined) ? null : res;
}
@@ -729,18 +730,10 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
if (existingTimeline) {
this._eventIdToTimeline.delete(oldEventId);
this._eventIdToTimeline.set(newEventId, existingTimeline);
} else {
if (this.filter) {
if (this.filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
}
} else {
this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
}
} else if (!this.filter || this.filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
}
}
+1 -1
View File
@@ -307,7 +307,7 @@ export class EventTimeline {
*
* @param {?string} token pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to set the pagination
* @param {string} direction EventTimeline.BACKWARDS to set the paginatio
* token for going backwards in time; EventTimeline.FORWARDS to set the
* pagination token for going forwards in time.
*/
+6 -1
View File
@@ -151,6 +151,7 @@ interface IKeyRequestRecipient {
export interface IDecryptOptions {
emit?: boolean;
isRetry?: boolean;
forceRedecryptIfUntrusted?: boolean;
}
/**
@@ -677,6 +678,8 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
* @param {object} options
* @param {boolean} options.isRetry True if this is a retry (enables more logging)
* @param {boolean} options.emit Emits "event.decrypted" if set to true
* @param {boolean} options.forceRedecryptIfUntrusted whether the message should be
* re-decrypted if it was previously successfully decrypted with an untrusted key
*
* @returns {Promise} promise which resolves (to undefined) when the decryption
* attempt is completed.
@@ -695,7 +698,9 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
throw new Error("Attempt to decrypt event which isn't encrypted");
}
if (this.clearEvent && !this.isDecryptionFailure()) {
const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure();
const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted();
if (alreadyDecrypted && !forceRedecrypt) {
// we may want to just ignore this? let's start with rejecting it.
throw new Error(
"Attempt to decrypt event which has already been decrypted",
+361
View File
@@ -0,0 +1,361 @@
/*
Copyright 2022 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 { UnstableValue } from "matrix-events-sdk";
import { MatrixClient } from "../client";
import { MatrixEvent } from "./event";
import { EventTimeline } from "./event-timeline";
import { Preset } from "../@types/partials";
import { globToRegexp } from "../utils";
import { Room } from "./room";
/// The event type storing the user's individual policies.
///
/// Exported for testing purposes.
export const POLICIES_ACCOUNT_EVENT_TYPE = new UnstableValue("m.policies", "org.matrix.msc3847.policies");
/// The key within the user's individual policies storing the user's ignored invites.
///
/// Exported for testing purposes.
export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue("m.ignore.invites",
"org.matrix.msc3847.ignore.invites");
/// The types of recommendations understood.
enum PolicyRecommendation {
Ban = "m.ban",
}
/**
* The various scopes for policies.
*/
export enum PolicyScope {
/**
* The policy deals with an individual user, e.g. reject invites
* from this user.
*/
User = "m.policy.user",
/**
* The policy deals with a room, e.g. reject invites towards
* a specific room.
*/
Room = "m.policy.room",
/**
* The policy deals with a server, e.g. reject invites from
* this server.
*/
Server = "m.policy.server",
}
/**
* A container for ignored invites.
*
* # Performance
*
* This implementation is extremely naive. It expects that we are dealing
* with a very short list of sources (e.g. only one). If real-world
* applications turn out to require longer lists, we may need to rework
* our data structures.
*/
export class IgnoredInvites {
constructor(
private readonly client: MatrixClient,
) {
}
/**
* Add a new rule.
*
* @param scope The scope for this rule.
* @param entity The entity covered by this rule. Globs are supported.
* @param reason A human-readable reason for introducing this new rule.
* @return The event id for the new rule.
*/
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
const target = await this.getOrCreateTargetRoom();
const response = await this.client.sendStateEvent(target.roomId, scope, {
entity,
reason,
recommendation: PolicyRecommendation.Ban,
});
return response.event_id;
}
/**
* Remove a rule.
*/
public async removeRule(event: MatrixEvent) {
await this.client.redactEvent(event.getRoomId()!, event.getId()!);
}
/**
* Add a new room to the list of sources. If the user isn't a member of the
* room, attempt to join it.
*
* @param roomId A valid room id. If this room is already in the list
* of sources, it will not be duplicated.
* @return `true` if the source was added, `false` if it was already present.
* @throws If `roomId` isn't the id of a room that the current user is already
* member of or can join.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async addSource(roomId: string): Promise<boolean> {
// We attempt to join the room *before* calling
// `await this.getOrCreateSourceRooms()` to decrease the duration
// of the racy section.
await this.client.joinRoom(roomId);
// Race starts.
const sources = (await this.getOrCreateSourceRooms())
.map(room => room.roomId);
if (sources.includes(roomId)) {
return false;
}
sources.push(roomId);
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.sources = sources;
});
// Race ends.
return true;
}
/**
* Find out whether an invite should be ignored.
*
* @param sender The user id for the user who issued the invite.
* @param roomId The room to which the user is invited.
* @returns A rule matching the entity, if any was found, `null` otherwise.
*/
public async getRuleForInvite({ sender, roomId }: {
sender: string;
roomId: string;
}): Promise<Readonly<MatrixEvent | null>> {
// In this implementation, we perform a very naive lookup:
// - search in each policy room;
// - turn each (potentially glob) rule entity into a regexp.
//
// Real-world testing will tell us whether this is performant enough.
// In the (unfortunately likely) case it isn't, there are several manners
// in which we could optimize this:
// - match several entities per go;
// - pre-compile each rule entity into a regexp;
// - pre-compile entire rooms into a single regexp.
const policyRooms = await this.getOrCreateSourceRooms();
const senderServer = sender.split(":")[1];
const roomServer = roomId.split(":")[1];
for (const room of policyRooms) {
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const { scope, entities } of [
{ scope: PolicyScope.Room, entities: [roomId] },
{ scope: PolicyScope.User, entities: [sender] },
{ scope: PolicyScope.Server, entities: [senderServer, roomServer] },
]) {
const events = state.getStateEvents(scope);
for (const event of events) {
const content = event.getContent();
if (content?.recommendation != PolicyRecommendation.Ban) {
// Ignoring invites only looks at `m.ban` recommendations.
continue;
}
const glob = content?.entity;
if (!glob) {
// Invalid event.
continue;
}
let regexp: RegExp;
try {
regexp = new RegExp(globToRegexp(glob, false));
} catch (ex) {
// Assume invalid event.
continue;
}
for (const entity of entities) {
if (entity && regexp.test(entity)) {
return event;
}
}
// No match.
}
}
}
return null;
}
/**
* Get the target room, i.e. the room in which any new rule should be written.
*
* If there is no target room setup, a target room is created.
*
* Note: This method is public for testing reasons. Most clients should not need
* to call it directly.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async getOrCreateTargetRoom(): Promise<Room> {
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
let target = ignoreInvitesPolicies.target;
// Validate `target`. If it is invalid, trash out the current `target`
// and create a new room.
if (typeof target !== "string") {
target = null;
}
if (target) {
// Check that the room exists and is valid.
const room = this.client.getRoom(target);
if (room) {
return room;
} else {
target = null;
}
}
// We need to create our own policy room for ignoring invites.
target = (await this.client.createRoom({
name: "Individual Policy Room",
preset: Preset.PrivateChat,
})).room_id;
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.target = target;
});
// Since we have just called `createRoom`, `getRoom` should not be `null`.
return this.client.getRoom(target)!;
}
/**
* Get the list of source rooms, i.e. the rooms from which rules need to be read.
*
* If no source rooms are setup, the target room is used as sole source room.
*
* Note: This method is public for testing reasons. Most clients should not need
* to call it directly.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async getOrCreateSourceRooms(): Promise<Room[]> {
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
let sources = ignoreInvitesPolicies.sources;
// Validate `sources`. If it is invalid, trash out the current `sources`
// and create a new list of sources from `target`.
let hasChanges = false;
if (!Array.isArray(sources)) {
// `sources` could not be an array.
hasChanges = true;
sources = [];
}
let sourceRooms: Room[] = sources
// `sources` could contain non-string / invalid room ids
.filter(roomId => typeof roomId === "string")
.map(roomId => this.client.getRoom(roomId))
.filter(room => !!room);
if (sourceRooms.length != sources.length) {
hasChanges = true;
}
if (sourceRooms.length == 0) {
// `sources` could be empty (possibly because we've removed
// invalid content)
const target = await this.getOrCreateTargetRoom();
hasChanges = true;
sourceRooms = [target];
}
if (hasChanges) {
// Reload `policies`/`ignoreInvitesPolicies` in case it has been changed
// during or by our call to `this.getTargetRoom()`.
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.sources = sources;
});
}
return sourceRooms;
}
/**
* Fetch the `IGNORE_INVITES_POLICIES` object from account data.
*
* If both an unstable prefix version and a stable prefix version are available,
* it will return the stable prefix version preferentially.
*
* The result is *not* validated but is guaranteed to be a non-null object.
*
* @returns A non-null object.
*/
private getIgnoreInvitesPolicies(): {[key: string]: any} {
return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
}
/**
* Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
*/
private async withIgnoreInvitesPolicies(cb: (ignoreInvitesPolicies: {[key: string]: any}) => void) {
const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
cb(ignoreInvitesPolicies);
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
}
/**
* As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE`
* object.
*/
private getPoliciesAndIgnoreInvitesPolicies():
{policies: {[key: string]: any}, ignoreInvitesPolicies: {[key: string]: any}} {
let policies = {};
for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) {
if (!key) {
continue;
}
const value = this.client.getAccountData(key)?.getContent();
if (value) {
policies = value;
break;
}
}
let ignoreInvitesPolicies = {};
let hasIgnoreInvitesPolicies = false;
for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) {
if (!key) {
continue;
}
const value = policies[key];
if (value && typeof value == "object") {
ignoreInvitesPolicies = value;
hasIgnoreInvitesPolicies = true;
break;
}
}
if (!hasIgnoreInvitesPolicies) {
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
}
return { policies, ignoreInvitesPolicies };
}
}
+306
View File
@@ -0,0 +1,306 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../@types/read_receipts";
import { EventTimelineSet, EventType, MatrixEvent } from "../matrix";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
export const MAIN_ROOM_TIMELINE = "main";
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
return new MatrixEvent({
content: {
[event.getId()]: {
[receiptType]: {
[userId]: {
ts: event.getTs(),
threadId: event.threadRootId ?? MAIN_ROOM_TIMELINE,
},
},
},
},
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
export interface Receipt {
ts: number;
thread_id?: string;
}
export interface WrappedReceipt {
eventId: string;
data: Receipt;
}
interface CachedReceipt {
type: ReceiptType;
userId: string;
data: Receipt;
}
type ReceiptCache = {[eventId: string]: CachedReceipt[]};
export interface ReceiptContent {
[eventId: string]: {
[key in ReceiptType]: {
[userId: string]: Receipt;
};
};
}
const ReceiptPairRealIndex = 0;
const ReceiptPairSyntheticIndex = 1;
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
type Receipts = {
[receiptType: string]: {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
export abstract class ReadReceipt<
Events extends string,
Arguments extends ListenerMap<Events>,
SuperclassArguments extends ListenerMap<any> = Arguments,
> extends TypedEventEmitter<Events, Arguments, SuperclassArguments> {
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
private receipts: Receipts = {}; // { receipt_type: { user_id: Receipt } }
private receiptCacheByEventId: ReceiptCache = {}; // { event_id: CachedReceipt[] }
public abstract getUnfilteredTimelineSet(): EventTimelineSet;
public abstract timeline: MatrixEvent[];
/**
* Gets the latest receipt for a given user in the room
* @param userId The id of the user for which we want the receipt
* @param ignoreSynthesized Whether to ignore synthesized receipts or not
* @param receiptType Optional. The type of the receipt we want to get
* @returns the latest receipts of the chosen type for the chosen user
*/
public getReadReceiptForUserId(
userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read,
): WrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? [];
if (ignoreSynthesized) {
return realReceipt;
}
return syntheticReceipt ?? realReceipt;
}
/**
* Get the ID of the event that a given user has read up to, or null if we
* have received no read receipts from them.
* @param {String} userId The user ID to get read receipt event ID for
* @param {Boolean} ignoreSynthesized If true, return only receipts that have been
* sent by the server, not implicit ones generated
* by the JS SDK.
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
// XXX: This is very very ugly and I hope I won't have to ever add a new
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// If we have both, compare them
let comparison: number | null | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) {
comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
}
// The public receipt is more likely to drift out of date so the private
// one has precedence
if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
// If public read receipt is older, return the private one
return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null;
}
public addReceiptToStructure(
eventId: string,
receiptType: ReceiptType,
userId: string,
receipt: Receipt,
synthetic: boolean,
): void {
if (!this.receipts[receiptType]) {
this.receipts[receiptType] = {};
}
if (!this.receipts[receiptType][userId]) {
this.receipts[receiptType][userId] = [null, null];
}
const pair = this.receipts[receiptType][userId];
let existingReceipt = pair[ReceiptPairRealIndex];
if (synthetic) {
existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
}
if (existingReceipt) {
// we only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
existingReceipt.eventId,
eventId,
);
if (ordering !== null && ordering >= 0) {
return;
}
}
const wrappedReceipt: WrappedReceipt = {
eventId,
data: receipt,
};
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
let ordering: number | null = null;
if (realReceipt && syntheticReceipt) {
ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
realReceipt.eventId,
syntheticReceipt.eventId,
);
}
const preferSynthetic = ordering === null || ordering < 0;
// we don't bother caching just real receipts by event ID as there's nothing that would read it.
// Take the current cached receipt before we overwrite the pair elements.
const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (synthetic && preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = wrappedReceipt;
} else if (!synthetic) {
pair[ReceiptPairRealIndex] = wrappedReceipt;
if (!preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = null;
}
}
const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (cachedReceipt === newCachedReceipt) return;
// clean up any previous cache entry
if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) {
const previousEventId = cachedReceipt.eventId;
// Remove the receipt we're about to clobber out of existence from the cache
this.receiptCacheByEventId[previousEventId] = (
this.receiptCacheByEventId[previousEventId].filter(r => {
return r.type !== receiptType || r.userId !== userId;
})
);
if (this.receiptCacheByEventId[previousEventId].length < 1) {
delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys
}
}
// cache the new one
if (!this.receiptCacheByEventId[eventId]) {
this.receiptCacheByEventId[eventId] = [];
}
this.receiptCacheByEventId[eventId].push({
userId: userId,
type: receiptType as ReceiptType,
data: receipt,
});
}
/**
* Get a list of receipts for the given event.
* @param {MatrixEvent} event the event to get receipts for
* @return {Object[]} A list of receipts with a userId, type and data keys or
* an empty list.
*/
public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] {
return this.receiptCacheByEventId[event.getId()] || [];
}
public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void;
/**
* Add a temporary local-echo receipt to the room to reflect in the
* client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged
* @param {ReceiptType} receiptType The type of receipt
*/
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
}
/**
* Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for.
* @return {String[]} A list of user IDs.
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return utils.isSupportedReceiptType(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
}
/**
* Determines if the given user has read a particular event ID with the known
* history of the room. This is not a definitive check as it relies only on
* what is available to the room at the time of execution.
* @param {String} userId The user ID to check the read state of.
* @param {String} eventId The event ID to check if the user read.
* @returns {Boolean} True if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
const readUpToId = this.getEventReadUpTo(userId, false);
if (readUpToId === eventId) return true;
if (this.timeline.length
&& this.timeline[this.timeline.length - 1].getSender()
&& this.timeline[this.timeline.length - 1].getSender() === userId) {
// It doesn't matter where the event is in the timeline, the user has read
// it because they've sent the latest event.
return true;
}
for (let i = this.timeline.length - 1; i >= 0; --i) {
const ev = this.timeline[i];
// If we encounter the target event first, the user hasn't read it
// however if we encounter the readUpToId first then the user has read
// it. These rules apply because we're iterating bottom-up.
if (ev.getId() === eventId) return false;
if (ev.getId() === readUpToId) return true;
}
// We don't know if the user has read it, so assume not.
return false;
}
}
+65 -352
View File
@@ -47,10 +47,16 @@ import {
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread";
import { TypedEventEmitter } from "./typed-event-emitter";
import { ReceiptType } from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import {
MAIN_ROOM_TIMELINE,
ReadReceipt,
Receipt,
ReceiptContent,
synthesizeReceipt,
} from "./read-receipt";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -61,23 +67,6 @@ import { RelationsContainer } from "./relations-container";
export const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
// console.log("synthesizing receipt for "+event.getId());
return new MatrixEvent({
content: {
[event.getId()]: {
[receiptType]: {
[userId]: {
ts: event.getTs(),
},
},
},
},
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
interface IOpts {
storageToken?: string;
pendingEventOrdering?: PendingEventOrdering;
@@ -91,40 +80,6 @@ export interface IRecommendedVersion {
urgent: boolean;
}
interface IReceipt {
ts: number;
}
export interface IWrappedReceipt {
eventId: string;
data: IReceipt;
}
interface ICachedReceipt {
type: ReceiptType;
userId: string;
data: IReceipt;
}
type ReceiptCache = {[eventId: string]: ICachedReceipt[]};
interface IReceiptContent {
[eventId: string]: {
[key in ReceiptType]: {
[userId: string]: IReceipt;
};
};
}
const ReceiptPairRealIndex = 0;
const ReceiptPairSyntheticIndex = 1;
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
type Receipts = {
[receiptType: string]: {
[userId: string]: [IWrappedReceipt, IWrappedReceipt]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
// When inserting a visibility event affecting event `eventId`, we
// need to scan through existing visibility events for `eventId`.
// In theory, this could take an unlimited amount of time if:
@@ -225,15 +180,9 @@ export type RoomEventHandlerMap = {
BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange
>;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> {
export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } }
private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] }
private notificationCounts: Partial<Record<NotificationCountType, number>> = {};
private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = [];
@@ -1981,14 +1930,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
}
if (event.getUnsigned().transaction_id) {
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id];
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
}
}
}
/**
@@ -1996,7 +1937,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {IAddLiveEventOptions} options addLiveEvent options
* @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
@@ -2344,7 +2285,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
fromCache = false,
): void {
let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean;
let timelineWasEmpty = false;
if (typeof (duplicateStrategyOrOpts) === 'object') {
({
duplicateStrategy,
@@ -2383,27 +2324,38 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = this.findThreadRoots(events);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
const options: IAddLiveEventOptions = {
duplicateStrategy,
fromCache,
timelineWasEmpty,
};
for (const event of events) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
this.processLiveEvent(event);
if (event.getUnsigned().transaction_id) {
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id!];
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
continue; // we can skip adding the event to the timeline sets, it is already there
}
}
const {
shouldLiveInRoom,
shouldLiveInThread,
threadId,
} = this.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
eventsByThread[threadId ?? ""] = [];
}
eventsByThread[threadId]?.push(event);
eventsByThread[threadId ?? ""]?.push(event);
if (shouldLiveInRoom) {
this.addLiveEvent(event, {
duplicateStrategy,
fromCache,
timelineWasEmpty,
});
this.addLiveEvent(event, options);
}
}
@@ -2433,17 +2385,17 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
if (shouldLiveInThread) {
event.setThreadId(threadId);
event.setThreadId(threadId ?? "");
memo[THREAD].push(event);
}
return memo;
}, [[], []]);
}, [[] as MatrixEvent[], [] as MatrixEvent[]]);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [
events,
[],
events as MatrixEvent[],
[] as MatrixEvent[],
];
}
}
@@ -2455,12 +2407,43 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = new Set<string>();
for (const event of events) {
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId);
threadRoots.add(event.relationEventId ?? "");
}
}
return threadRoots;
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
public addReceipt(event: MatrixEvent, synthetic = false): void {
const content = event.getContent<ReceiptContent>();
Object.keys(content).forEach((eventId: string) => {
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
const receipt = content[eventId][receiptType][userId] as Receipt;
const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE;
const receiptDestination: Thread | this | undefined = receiptForMainTimeline
? this
: this.threads.get(receipt.thread_id ?? "");
receiptDestination?.addReceiptToStructure(
eventId,
receiptType as ReceiptType,
userId,
receipt,
synthetic,
);
});
});
});
// send events after we've regenerated the structure & cache, otherwise things that
// listened for the event would read stale data.
this.emit(RoomEvent.Receipt, event, this);
}
/**
* Adds/handles ephemeral events such as typing notifications and read receipts.
* @param {MatrixEvent[]} events A list of events to process
@@ -2551,276 +2534,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
/**
* Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for.
* @return {String[]} A list of user IDs.
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return utils.isSupportedReceiptType(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
}
/**
* Gets the latest receipt for a given user in the room
* @param userId The id of the user for which we want the receipt
* @param ignoreSynthesized Whether to ignore synthesized receipts or not
* @param receiptType Optional. The type of the receipt we want to get
* @returns the latest receipts of the chosen type for the chosen user
*/
public getReadReceiptForUserId(
userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read,
): IWrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? [];
if (ignoreSynthesized) {
return realReceipt;
}
return syntheticReceipt ?? realReceipt;
}
/**
* Get the ID of the event that a given user has read up to, or null if we
* have received no read receipts from them.
* @param {String} userId The user ID to get read receipt event ID for
* @param {Boolean} ignoreSynthesized If true, return only receipts that have been
* sent by the server, not implicit ones generated
* by the JS SDK.
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
// XXX: This is very very ugly and I hope I won't have to ever add a new
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.Read,
);
const privateReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.ReadPrivate,
);
const unstablePrivateReadReceipt = this.getReadReceiptForUserId(
userId,
ignoreSynthesized,
ReceiptType.UnstableReadPrivate,
);
// If we have all, compare them
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId && unstablePrivateReadReceipt?.eventId) {
const comparison1 = timelineSet.compareEventOrdering(
publicReadReceipt.eventId,
privateReadReceipt.eventId,
);
const comparison2 = timelineSet.compareEventOrdering(
publicReadReceipt.eventId,
unstablePrivateReadReceipt.eventId,
);
const comparison3 = timelineSet.compareEventOrdering(
privateReadReceipt.eventId,
unstablePrivateReadReceipt.eventId,
);
if (comparison1 && comparison2 && comparison3) {
return (comparison1 > 0)
? ((comparison2 > 0) ? publicReadReceipt.eventId : unstablePrivateReadReceipt.eventId)
: ((comparison3 > 0) ? privateReadReceipt.eventId : unstablePrivateReadReceipt.eventId);
}
}
let latest = privateReadReceipt;
[unstablePrivateReadReceipt, publicReadReceipt].forEach((receipt) => {
if (receipt?.data?.ts > latest?.data?.ts || !latest) {
latest = receipt;
}
});
if (latest?.eventId) return latest?.eventId;
// The more less likely it is for a read receipt to drift out of date
// the bigger is its precedence
return (
privateReadReceipt?.eventId ??
unstablePrivateReadReceipt?.eventId ??
publicReadReceipt?.eventId ??
null
);
}
/**
* Determines if the given user has read a particular event ID with the known
* history of the room. This is not a definitive check as it relies only on
* what is available to the room at the time of execution.
* @param {String} userId The user ID to check the read state of.
* @param {String} eventId The event ID to check if the user read.
* @returns {Boolean} True if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
const readUpToId = this.getEventReadUpTo(userId, false);
if (readUpToId === eventId) return true;
if (this.timeline.length
&& this.timeline[this.timeline.length - 1].getSender()
&& this.timeline[this.timeline.length - 1].getSender() === userId) {
// It doesn't matter where the event is in the timeline, the user has read
// it because they've sent the latest event.
return true;
}
for (let i = this.timeline.length - 1; i >= 0; --i) {
const ev = this.timeline[i];
// If we encounter the target event first, the user hasn't read it
// however if we encounter the readUpToId first then the user has read
// it. These rules apply because we're iterating bottom-up.
if (ev.getId() === eventId) return false;
if (ev.getId() === readUpToId) return true;
}
// We don't know if the user has read it, so assume not.
return false;
}
/**
* Get a list of receipts for the given event.
* @param {MatrixEvent} event the event to get receipts for
* @return {Object[]} A list of receipts with a userId, type and data keys or
* an empty list.
*/
public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] {
return this.receiptCacheByEventId[event.getId()] || [];
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
public addReceipt(event: MatrixEvent, synthetic = false): void {
this.addReceiptsToStructure(event, synthetic);
// send events after we've regenerated the structure & cache, otherwise things that
// listened for the event would read stale data.
this.emit(RoomEvent.Receipt, event, this);
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
private addReceiptsToStructure(event: MatrixEvent, synthetic: boolean): void {
const content = event.getContent<IReceiptContent>();
Object.keys(content).forEach((eventId) => {
Object.keys(content[eventId]).forEach((receiptType) => {
Object.keys(content[eventId][receiptType]).forEach((userId) => {
const receipt = content[eventId][receiptType][userId];
if (!this.receipts[receiptType]) {
this.receipts[receiptType] = {};
}
if (!this.receipts[receiptType][userId]) {
this.receipts[receiptType][userId] = [null, null];
}
const pair = this.receipts[receiptType][userId];
let existingReceipt = pair[ReceiptPairRealIndex];
if (synthetic) {
existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
}
if (existingReceipt) {
// we only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
existingReceipt.eventId,
eventId,
);
if (ordering !== null && ordering >= 0) {
return;
}
}
const wrappedReceipt: IWrappedReceipt = {
eventId,
data: receipt,
};
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
let ordering: number | null = null;
if (realReceipt && syntheticReceipt) {
ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
realReceipt.eventId,
syntheticReceipt.eventId,
);
}
const preferSynthetic = ordering === null || ordering < 0;
// we don't bother caching just real receipts by event ID as there's nothing that would read it.
// Take the current cached receipt before we overwrite the pair elements.
const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (synthetic && preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = wrappedReceipt;
} else if (!synthetic) {
pair[ReceiptPairRealIndex] = wrappedReceipt;
if (!preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = null;
}
}
const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (cachedReceipt === newCachedReceipt) return;
// clean up any previous cache entry
if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) {
const previousEventId = cachedReceipt.eventId;
// Remove the receipt we're about to clobber out of existence from the cache
this.receiptCacheByEventId[previousEventId] = (
this.receiptCacheByEventId[previousEventId].filter(r => {
return r.type !== receiptType || r.userId !== userId;
})
);
if (this.receiptCacheByEventId[previousEventId].length < 1) {
delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys
}
}
// cache the new one
if (!this.receiptCacheByEventId[eventId]) {
this.receiptCacheByEventId[eventId] = [];
}
this.receiptCacheByEventId[eventId].push({
userId: userId,
type: receiptType as ReceiptType,
data: receipt,
});
});
});
});
}
/**
* Add a temporary local-echo receipt to the room to reflect in the
* client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged
* @param {ReceiptType} receiptType The type of receipt
*/
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
}
/**
* Update the room-tag event for the room. The previous one is overwritten.
* @param {MatrixEvent} event the m.tag event
+16 -4
View File
@@ -23,10 +23,10 @@ import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
export enum ThreadEvent {
New = "Thread.new",
@@ -54,7 +54,7 @@ interface IThreadOpts {
/**
* @experimental
*/
export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport: boolean;
/**
@@ -335,7 +335,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
/**
* Return last reply to the thread, if known.
*/
public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): Optional<MatrixEvent> {
public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): MatrixEvent | null {
for (let i = this.events.length - 1; i >= 0; i--) {
const event = this.events[i];
if (matches(event)) {
@@ -384,7 +384,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, direction: Direction.Backward }): Promise<{
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
nextBatch?: string | null;
prevBatch?: string;
}> {
let {
@@ -429,6 +429,18 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
nextBatch,
};
}
public getUnfilteredTimelineSet(): EventTimelineSet {
return this.timelineSet;
}
public get timeline(): MatrixEvent[] {
return this.events;
}
public addReceipt(event: MatrixEvent, synthetic: boolean): void {
throw new Error("Unsupported function on the thread model");
}
}
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
+11 -1
View File
@@ -293,13 +293,16 @@ export class SlidingSyncSdk {
this.processRoomData(this.client, room, roomData);
}
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err?: Error): void {
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null): void {
if (err) {
logger.debug("onLifecycle", state, err);
}
switch (state) {
case SlidingSyncState.Complete:
this.purgeNotifications();
if (!resp) {
break;
}
// Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared
if (!this.lastPos) {
this.updateSyncState(SyncState.Prepared, {
@@ -472,6 +475,13 @@ export class SlidingSyncSdk {
}
}
if (Number.isInteger(roomData.invited_count)) {
room.currentState.setInvitedMemberCount(roomData.invited_count!);
}
if (Number.isInteger(roomData.joined_count)) {
room.currentState.setJoinedMemberCount(roomData.joined_count!);
}
if (roomData.invite_state) {
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
this.processRoomEvents(room, inviteStateEvents);
+5 -1
View File
@@ -84,6 +84,8 @@ export interface MSC3575RoomData {
timeline: (IRoomEvent | IStateEvent)[];
notification_count?: number;
highlight_count?: number;
joined_count?: number;
invited_count?: number;
invite_state?: IStateEvent[];
initial?: boolean;
limited?: boolean;
@@ -320,7 +322,9 @@ export enum SlidingSyncEvent {
export type SlidingSyncEventHandlerMap = {
[SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void;
[SlidingSyncEvent.Lifecycle]: (state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err: Error) => void;
[SlidingSyncEvent.Lifecycle]: (
state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null,
) => void;
[SlidingSyncEvent.List]: (
listIndex: number, joinedCount: number, roomIndexToRoomId: Record<number, string>,
) => void;
+1 -1
View File
@@ -162,7 +162,7 @@ export interface IStore {
* Get account data event by event type
* @param {string} eventType The event type being queried
*/
getAccountData(eventType: EventType | string): MatrixEvent;
getAccountData(eventType: EventType | string): MatrixEvent | undefined;
/**
* setSyncData does nothing as there is no backing data store.
+1 -1
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ISavedSync } from "./index";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "../matrix";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
export interface IIndexedDBBackend {
+29 -41
View File
@@ -18,41 +18,36 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn
import * as utils from "../utils";
import * as IndexedDBHelpers from "../indexeddb-helpers";
import { logger } from '../logger';
import { IStartClientOpts, IStateEventWithRoomId } from "..";
import { IStartClientOpts, IStateEventWithRoomId } from "../matrix";
import { ISavedSync } from "./index";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
const VERSION = 4;
type DbMigration = (db: IDBDatabase) => void;
const DB_MIGRATIONS: DbMigration[] = [
(db) => {
// Make user store, clobber based on user ID. (userId property of User objects)
db.createObjectStore("users", { keyPath: ["userId"] });
function createDatabase(db: IDBDatabase): void {
// Make user store, clobber based on user ID. (userId property of User objects)
db.createObjectStore("users", { keyPath: ["userId"] });
// Make account data store, clobber based on event type.
// (event.type property of MatrixEvent objects)
db.createObjectStore("accountData", { keyPath: ["type"] });
// Make account data store, clobber based on event type.
// (event.type property of MatrixEvent objects)
db.createObjectStore("accountData", { keyPath: ["type"] });
// Make /sync store (sync tokens, room data, etc), always clobber (const key).
db.createObjectStore("sync", { keyPath: ["clobber"] });
}
function upgradeSchemaV2(db: IDBDatabase): void {
const oobMembersStore = db.createObjectStore(
"oob_membership_events", {
keyPath: ["room_id", "state_key"],
});
oobMembersStore.createIndex("room", "room_id");
}
function upgradeSchemaV3(db: IDBDatabase): void {
db.createObjectStore("client_options",
{ keyPath: ["clobber"] });
}
function upgradeSchemaV4(db: IDBDatabase): void {
db.createObjectStore("to_device_queue", { autoIncrement: true });
}
// Make /sync store (sync tokens, room data, etc), always clobber (const key).
db.createObjectStore("sync", { keyPath: ["clobber"] });
},
(db) => {
const oobMembersStore = db.createObjectStore(
"oob_membership_events", {
keyPath: ["room_id", "state_key"],
});
oobMembersStore.createIndex("room", "room_id");
},
(db) => { db.createObjectStore("client_options", { keyPath: ["clobber"] }); },
(db) => { db.createObjectStore("to_device_queue", { autoIncrement: true }); },
// Expand as needed.
];
const VERSION = DB_MIGRATIONS.length;
/**
* Helper method to collect results from a Cursor and promiseify it.
@@ -172,20 +167,13 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
logger.log(
`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`,
);
if (oldVersion < 1) { // The database did not previously exist.
if (oldVersion < 1) {
// The database did not previously exist
this._isNewlyCreated = true;
createDatabase(db);
}
if (oldVersion < 2) {
upgradeSchemaV2(db);
}
if (oldVersion < 3) {
upgradeSchemaV3(db);
}
if (oldVersion < 4) {
upgradeSchemaV4(db);
}
// Expand as needed.
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
};
req.onblocked = () => {
+1 -1
View File
@@ -18,7 +18,7 @@ import { logger } from "../logger";
import { defer, IDeferred } from "../utils";
import { ISavedSync } from "./index";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId, ISyncResponse } from "..";
import { IStateEventWithRoomId, ISyncResponse } from "../matrix";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+8 -8
View File
@@ -66,7 +66,7 @@ interface IState {
export interface ITimeline {
events: Array<IRoomEvent | IStateEvent>;
limited?: boolean;
prev_batch: string;
prev_batch: string | null;
}
export interface IJoinedRoom {
@@ -401,7 +401,7 @@ export class SyncAccumulator {
// typing forever until someone really does start typing (which
// will prompt Synapse to send down an actual m.typing event to
// clobber the one we persisted).
if (e.type !== "m.receipt" || !e.content) {
if (e.type !== EventType.Receipt || !e.content) {
// This means we'll drop unknown ephemeral events but that
// seems okay.
return;
@@ -528,7 +528,7 @@ export class SyncAccumulator {
});
Object.keys(this.joinRooms).forEach((roomId) => {
const roomData = this.joinRooms[roomId];
const roomJson = {
const roomJson: IJoinedRoom = {
ephemeral: { events: [] },
account_data: { events: [] },
state: { events: [] },
@@ -541,12 +541,12 @@ export class SyncAccumulator {
};
// Add account data
Object.keys(roomData._accountData).forEach((evType) => {
roomJson.account_data.events.push(roomData._accountData[evType]);
roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent);
});
// Add receipt data
const receiptEvent = {
type: "m.receipt",
type: EventType.Receipt,
room_id: roomId,
content: {
// $event_id: { "m.read": { $user_id: $json } }
@@ -566,7 +566,7 @@ export class SyncAccumulator {
});
// add only if we have some receipt data
if (Object.keys(receiptEvent.content).length > 0) {
roomJson.ephemeral.events.push(receiptEvent);
roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent);
}
// Add timeline data
@@ -609,8 +609,8 @@ export class SyncAccumulator {
const rollBackState = Object.create(null);
for (let i = roomJson.timeline.events.length - 1; i >=0; i--) {
const timelineEvent = roomJson.timeline.events[i];
if (timelineEvent.state_key === null ||
timelineEvent.state_key === undefined) {
if ((timelineEvent as IStateEvent).state_key === null ||
(timelineEvent as IStateEvent).state_key === undefined) {
continue; // not a state event
}
// since we're going back in time, we need to use the previous
+43 -3
View File
@@ -1109,7 +1109,20 @@ export class SyncApi {
if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) {
const cancelledKeyVerificationTxns = [];
data.to_device.events
.map(client.getEventMapper())
.filter((eventJSON) => {
if (
eventJSON.type === EventType.RoomMessageEncrypted &&
!(["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm))
) {
logger.log(
'Ignoring invalid encrypted to-device event from ' + eventJSON.sender,
);
return false;
}
return true;
})
.map(client.getEventMapper({ toDevice: true }))
.map((toDeviceEvent) => { // map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
// if there's an accompanying m.key.verification.cancel event, so
@@ -1185,6 +1198,27 @@ export class SyncApi {
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
await this.processRoomEvents(room, stateEvents);
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId())?.getSender();
if (client.isCryptoEnabled()) {
const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
for (const parked of parkedHistory) {
if (parked.senderId === inviter) {
await client.crypto.olmDevice.addInboundGroupSession(
room.roomId,
parked.senderKey,
parked.forwardingCurve25519KeyChain,
parked.sessionId,
parked.sessionKey,
parked.keysClaimed,
true,
{ sharedHistory: true, untrusted: true },
);
}
}
}
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
@@ -1235,7 +1269,9 @@ export class SyncApi {
if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
if (joinObj.timeline.prev_batch !== null) {
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
}
} else if (joinObj.timeline.limited) {
let limited = true;
@@ -1288,7 +1324,11 @@ export class SyncApi {
}
}
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
try {
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
} catch (e) {
logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
// set summary after processing events,
// because it will trigger a name calculation
+4 -14
View File
@@ -24,7 +24,7 @@ import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import type * as NodeCrypto from "crypto";
import { MatrixClient, MatrixEvent } from ".";
import { MatrixEvent } from "./models/event";
import { M_TIMESTAMP } from "./@types/location";
import { ReceiptType } from "./@types/read_receipts";
@@ -341,7 +341,7 @@ export function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function globToRegexp(glob: string, extended?: any): string {
export function globToRegexp(glob: string, extended = false): string {
// From
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
// Because micromatch is about 130KB with dependencies,
@@ -349,7 +349,7 @@ export function globToRegexp(glob: string, extended?: any): string {
const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [
[/\\\*/g, '.*'],
[/\?/g, '.'],
extended !== false && [
!extended && [
/\\\[(!|)(.*)\\]/g,
(_match: string, neg: string, pat: string) => [
'[',
@@ -670,17 +670,7 @@ export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: Mat
return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
}
export async function getPrivateReadReceiptField(client: MatrixClient): Promise<ReceiptType | null> {
if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) return ReceiptType.ReadPrivate;
if (await client.doesServerSupportUnstableFeature("org.matrix.msc2285")) return ReceiptType.UnstableReadPrivate;
return null;
}
export function isSupportedReceiptType(receiptType: string): boolean {
return [
ReceiptType.Read,
ReceiptType.ReadPrivate,
ReceiptType.UnstableReadPrivate,
].includes(receiptType as ReceiptType);
return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType);
}
+941 -696
View File
File diff suppressed because it is too large Load Diff