Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb9e258468 | |||
| 974723ceef | |||
| 9b8dcf53ed | |||
| 324af3ee67 | |||
| ec6c0946d4 | |||
| e5f480b032 | |||
| 6bf4ed8672 | |||
| c18d691ef5 | |||
| 97cf73bc52 | |||
| aa25103665 | |||
| 858db67778 | |||
| e230abee45 | |||
| 8c16d69f3c | |||
| 55b9116c99 | |||
| 3a5d66057e | |||
| 3f7af189e4 | |||
| 16ddcb0ed0 | |||
| 9e35b8dd0a | |||
| bed787b749 | |||
| d260b8be56 | |||
| 97991dad02 | |||
| b8c19c47ab | |||
| 1476ffbd15 | |||
| 62f0a65472 | |||
| 2ef7ae7661 | |||
| 61c0a49971 | |||
| 2172f28888 | |||
| 2e9b34e0c3 | |||
| 5a782b7377 | |||
| 54bc807056 | |||
| 9e07710d80 | |||
| e9ed91d800 | |||
| 88ba4fad71 | |||
| 21b3471453 | |||
| 0ada9803ab | |||
| 1744f0e97b | |||
| fd0c4a7f56 | |||
| 615f7f9e72 | |||
| 77259e81c9 | |||
| 2193cd9d1c | |||
| 6d28154dcd | |||
| 83d447adfe | |||
| 73c9f4e322 | |||
| e6fa4cdb3c | |||
| a04653a72c | |||
| 5f9341f39c | |||
| 906946c419 | |||
| 4397b9d640 | |||
| 90da2cf439 | |||
| 6edd45787b | |||
| 84444ec11e | |||
| 0e95df5dba | |||
| 29b815b678 | |||
| 0cf056958b | |||
| 79d4113a6b | |||
| 8a80886358 | |||
| de7959de6c | |||
| 533c21a515 | |||
| 6b018b6927 | |||
| 38c3abb364 | |||
| a47f319665 | |||
| ecef9fd755 | |||
| 7dffd8ffd3 | |||
| 66492e7ba8 | |||
| 43b2404865 | |||
| fed9910fa1 | |||
| f77662406c | |||
| 8cc0cf1a70 | |||
| dfa2429094 | |||
| 3e2460707c | |||
| 706c084fa7 | |||
| eb7faa6c07 | |||
| d45a0b894a | |||
| 102739e0fb | |||
| 0d7e4a0fa5 | |||
| d4628e78d4 | |||
| 0b193f4665 | |||
| 8ef2e848b9 | |||
| d92936fba5 | |||
| f005984df3 | |||
| 13fec49e74 | |||
| 008294cfc6 | |||
| b05f933d83 | |||
| b186d79dde | |||
| e82b5fe1db | |||
| 9602aa88ea | |||
| 0fb3dc1b13 | |||
| aeede332be | |||
| b052950a19 | |||
| 1cb5fff5a1 | |||
| 01226e41d9 | |||
| e3919fd93b | |||
| 3b88ea19b7 | |||
| dcf26f3e48 | |||
| 3385adf5f6 | |||
| 9db6ce107a | |||
| d5b22e1deb | |||
| a5e606a1e7 | |||
| f2471b6dbd | |||
| dcf71e0c8f | |||
| 77267e393c | |||
| 1fdc0af5b7 | |||
| d2b782a2f5 | |||
| 5df4ebaada | |||
| e68a1471c1 | |||
| e42dd74426 | |||
| 2751e191d3 | |||
| b5b86bf1b5 | |||
| 4990bf5ca0 | |||
| b8fa030d5d |
@@ -66,9 +66,6 @@ module.exports = {
|
||||
// Disabled tests are a reality for now but as soon as all of the xits are
|
||||
// eliminated, we should enforce this.
|
||||
"jest/no-disabled-tests": "off",
|
||||
// TODO: There are many tests with invalid expects that should be fixed,
|
||||
// https://github.com/matrix-org/matrix-js-sdk/issues/2976
|
||||
"jest/valid-expect": "off",
|
||||
// Also treat "oldBackendOnly" as a test function.
|
||||
// Used in some crypto tests.
|
||||
"jest/no-standalone-expect": [
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
)
|
||||
)
|
||||
steps:
|
||||
- uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2
|
||||
- uses: tibdex/backport@9565281eda0731b1d20c4025c43339fb0a23812e # v2
|
||||
with:
|
||||
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
|
||||
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.74.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.77.1
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Build downstream artifacts
|
||||
on:
|
||||
pull_request: {}
|
||||
# We only want the Rust Crypto Cypress tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume 4 concurrent runners.
|
||||
# Anyone working on Rust Crypto is able to run the tests locally if required.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
@@ -19,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.74.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.77.1
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@a25b4180b728b0279fca97d4e5bccf391685aead # v2.2.0
|
||||
uses: JS-DevTools/npm-publish@5a85faf05d2ade2d5b6682bfe5359915d5159c6c # v2.2.1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
|
||||
@@ -1,3 +1,95 @@
|
||||
Changes in [28.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.0.0) (2023-08-29)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Set minimum supported Matrix 1.1 version (drop legacy r0 versions) ([\#3007](https://github.com/matrix-org/matrix-js-sdk/pull/3007)). Fixes vector-im/element-web#16876.
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* ElementR: Add `CryptoApi.requestVerificationDM` ([\#3643](https://github.com/matrix-org/matrix-js-sdk/pull/3643)). Contributed by @florianduros.
|
||||
* Implement `CryptoApi.checkKeyBackupAndEnable` ([\#3633](https://github.com/matrix-org/matrix-js-sdk/pull/3633)). Fixes vector-im/crypto-internal#111 and vector-im/crypto-internal#112.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* ElementR: Process all verification events, not just requests ([\#3650](https://github.com/matrix-org/matrix-js-sdk/pull/3650)). Contributed by @florianduros.
|
||||
|
||||
Changes in [27.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.2.0) (2023-08-15)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Allow knocking rooms ([\#3647](https://github.com/matrix-org/matrix-js-sdk/pull/3647)). Contributed by @charlynguyen.
|
||||
* Bump pagination limit to account for threaded events ([\#3638](https://github.com/matrix-org/matrix-js-sdk/pull/3638)).
|
||||
* ElementR: Add `CryptoApi.findVerificationRequestDMInProgress` ([\#3601](https://github.com/matrix-org/matrix-js-sdk/pull/3601)). Contributed by @florianduros.
|
||||
* Export more into the public interface ([\#3614](https://github.com/matrix-org/matrix-js-sdk/pull/3614)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix wrong handling of encrypted rooms when loading them from sync accumulator ([\#3640](https://github.com/matrix-org/matrix-js-sdk/pull/3640)). Fixes vector-im/element-web#25803.
|
||||
* Skip processing thread roots and fetching threads list when support is disabled ([\#3642](https://github.com/matrix-org/matrix-js-sdk/pull/3642)).
|
||||
* Ensure we don't overinflate the total notification count ([\#3634](https://github.com/matrix-org/matrix-js-sdk/pull/3634)). Fixes vector-im/element-web#25803.
|
||||
|
||||
Changes in [27.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.1.0) (2023-08-01)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* ElementR: Add `CryptoApi.getCrossSigningKeyId` ([\#3619](https://github.com/matrix-org/matrix-js-sdk/pull/3619)). Contributed by @florianduros.
|
||||
* ElementR: Stub `CheckOwnCrossSigningTrust`, import cross signing keys and verify local device in `bootstrapCrossSigning` ([\#3608](https://github.com/matrix-org/matrix-js-sdk/pull/3608)). Contributed by @florianduros.
|
||||
* Specify /preview_url requests as low priority ([\#3609](https://github.com/matrix-org/matrix-js-sdk/pull/3609)). Fixes vector-im/element-web#7292.
|
||||
* Element-R: support for displaying QR codes during verification ([\#3588](https://github.com/matrix-org/matrix-js-sdk/pull/3588)). Fixes vector-im/crypto-internal#124.
|
||||
* Add support for scanning QR codes during verification, with Rust crypto ([\#3565](https://github.com/matrix-org/matrix-js-sdk/pull/3565)).
|
||||
* Add methods to influence set_presence on /sync API calls ([\#3578](https://github.com/matrix-org/matrix-js-sdk/pull/3578)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix threads ending up with chunks of their timelines missing ([\#3618](https://github.com/matrix-org/matrix-js-sdk/pull/3618)). Fixes vector-im/element-web#24466.
|
||||
* Ensure we do not clobber a newer RR with an older unthreaded one ([\#3617](https://github.com/matrix-org/matrix-js-sdk/pull/3617)). Fixes vector-im/element-web#25806.
|
||||
* Fix registration check your emails stage regression ([\#3616](https://github.com/matrix-org/matrix-js-sdk/pull/3616)).
|
||||
* Fix how `Room::eventShouldLiveIn` handles replies to unknown parents ([\#3615](https://github.com/matrix-org/matrix-js-sdk/pull/3615)). Fixes vector-im/element-web#22603.
|
||||
* Only send threaded read receipts if threads support is enabled ([\#3612](https://github.com/matrix-org/matrix-js-sdk/pull/3612)).
|
||||
* ElementR: Fix `userId` parameter usage in `CryptoApi#getVerificationRequestsToDeviceInProgress` ([\#3611](https://github.com/matrix-org/matrix-js-sdk/pull/3611)). Contributed by @florianduros.
|
||||
* Fix edge cases around non-thread relations to thread roots and read receipts ([\#3607](https://github.com/matrix-org/matrix-js-sdk/pull/3607)).
|
||||
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
|
||||
* Export typed event emitter key types ([\#3597](https://github.com/matrix-org/matrix-js-sdk/pull/3597)). Fixes #3506.
|
||||
* Element-R: ensure that `userHasCrossSigningKeys` uses up-to-date data ([\#3599](https://github.com/matrix-org/matrix-js-sdk/pull/3599)). Fixes vector-im/element-web#25773.
|
||||
* Fix sending `auth: null` due to broken types around UIA ([\#3594](https://github.com/matrix-org/matrix-js-sdk/pull/3594)).
|
||||
|
||||
Changes in [27.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.0.0) (2023-07-18)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Drop support for Node 16 ([\#3533](https://github.com/matrix-org/matrix-js-sdk/pull/3533)).
|
||||
* Improve types around login, registration, UIA and identity servers ([\#3537](https://github.com/matrix-org/matrix-js-sdk/pull/3537)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
* Simplify `MatrixClient::setPowerLevel` API ([\#3570](https://github.com/matrix-org/matrix-js-sdk/pull/3570)). Fixes vector-im/element-web#13900 and #1844.
|
||||
* Deprecate `VerificationRequest.getQRCodeBytes` and replace it with the asynchronous `generateQRCode`. ([\#3562](https://github.com/matrix-org/matrix-js-sdk/pull/3562)).
|
||||
* Deprecate `VerificationRequest.beginKeyVerification()` in favour of `VerificationRequest.startVerification()`. ([\#3528](https://github.com/matrix-org/matrix-js-sdk/pull/3528)).
|
||||
* Deprecate `Crypto.VerificationRequest` application event, replacing it with `Crypto.VerificationRequestReceived`. ([\#3514](https://github.com/matrix-org/matrix-js-sdk/pull/3514)).
|
||||
|
||||
## ✨ Features
|
||||
* Throw saner error when peeking has its room pulled out from under it ([\#3577](https://github.com/matrix-org/matrix-js-sdk/pull/3577)). Fixes vector-im/element-web#18679.
|
||||
* OIDC: Log in ([\#3554](https://github.com/matrix-org/matrix-js-sdk/pull/3554)). Contributed by @kerryarchibald.
|
||||
* Prevent threads code from making identical simultaneous API hits ([\#3541](https://github.com/matrix-org/matrix-js-sdk/pull/3541)). Fixes vector-im/element-web#25395.
|
||||
* Update IUnsigned type to be extensible ([\#3547](https://github.com/matrix-org/matrix-js-sdk/pull/3547)).
|
||||
* add stop() api to BackupManager for clean shutdown ([\#3553](https://github.com/matrix-org/matrix-js-sdk/pull/3553)).
|
||||
* Log the message ID of any undecryptable to-device messages ([\#3543](https://github.com/matrix-org/matrix-js-sdk/pull/3543)).
|
||||
* Ignore thread relations on state events for consistency with edits ([\#3540](https://github.com/matrix-org/matrix-js-sdk/pull/3540)).
|
||||
* OIDC: validate id token ([\#3531](https://github.com/matrix-org/matrix-js-sdk/pull/3531)). Contributed by @kerryarchibald.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
|
||||
* Fix `TypedEventEmitter::removeAllListeners(void)` not working ([\#3561](https://github.com/matrix-org/matrix-js-sdk/pull/3561)).
|
||||
* Don't allow Olm unwedging rate-limiting to race ([\#3549](https://github.com/matrix-org/matrix-js-sdk/pull/3549)). Fixes vector-im/element-web#25716.
|
||||
* Fix an instance of failed to decrypt error when an in flight `/keys/query` fails. ([\#3486](https://github.com/matrix-org/matrix-js-sdk/pull/3486)).
|
||||
* Use the right anchor emoji for SAS verification ([\#3534](https://github.com/matrix-org/matrix-js-sdk/pull/3534)).
|
||||
* fix a bug which caused the wrong emoji to be shown during SAS device verification. ([\#3523](https://github.com/matrix-org/matrix-js-sdk/pull/3523)).
|
||||
|
||||
Changes in [26.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0) (2023-07-04)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
|
||||
browser or in Node.js.
|
||||
|
||||
#### Minimum Matrix server version: v1.1
|
||||
|
||||
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
|
||||
guarantees that a feature will be supported for at least 4 spec releases. For example, if a feature the js-sdk supports
|
||||
is removed in v1.4 then the feature is _eligible_ for removal from the SDK when v1.8 is released. This SDK has no
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "26.2.0",
|
||||
"version": "28.0.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -32,8 +32,8 @@
|
||||
"keywords": [
|
||||
"matrix-org"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"browser": "./src/browser-index.ts",
|
||||
"main": "./lib/index.js",
|
||||
"browser": "./lib/browser-index.js",
|
||||
"matrix_src_main": "./src/index.ts",
|
||||
"matrix_src_browser": "./src/browser-index.ts",
|
||||
"matrix_lib_main": "./lib/index.js",
|
||||
@@ -55,14 +55,15 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.1",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.1",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"loglevel": "^1.7.1",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.3.1",
|
||||
"matrix-widget-api": "^1.5.0",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
@@ -96,22 +97,20 @@
|
||||
"allchange": "^1.0.6",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"better-docs": "^2.4.0-beta.9",
|
||||
"browserify": "^17.0.0",
|
||||
"browserify-swap": "^0.2.2",
|
||||
"debug": "^4.3.4",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -157,5 +156,6 @@
|
||||
"no-rust-crypto": {
|
||||
"src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
}
|
||||
|
||||
+1
-18
@@ -32,8 +32,6 @@ import { syncPromise } from "./test-utils/test-utils";
|
||||
import { createClient, IStartClientOpts } from "../src/matrix";
|
||||
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
|
||||
import { MockStorageApi } from "./MockStorageApi";
|
||||
import { encodeUri } from "../src/utils";
|
||||
import { IKeyBackupSession } from "../src/crypto/keybackup";
|
||||
import { IKeysUploadResponse, IUploadKeysRequest } from "../src/client";
|
||||
import { ISyncResponder } from "./test-utils/SyncResponder";
|
||||
|
||||
@@ -92,7 +90,7 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
|
||||
logger.log(this + ": starting");
|
||||
this.httpBackend.when("GET", "/versions").respond(200, {
|
||||
// we have tests that rely on support for lazy-loading members
|
||||
versions: ["r0.5.0"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
this.httpBackend.when("GET", "/pushrules").respond(200, {});
|
||||
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
@@ -214,21 +212,6 @@ export class TestClient implements IE2EKeyReceiver, ISyncResponder {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up expectations that the client will query key backups for a particular session
|
||||
*/
|
||||
public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) {
|
||||
this.httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
encodeUri("/room_keys/keys/$roomId/$sessionId", {
|
||||
$roomId: roomId,
|
||||
$sessionId: sessionId,
|
||||
}),
|
||||
)
|
||||
.respond(status, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* get the uploaded curve25519 device key
|
||||
*
|
||||
|
||||
@@ -18,9 +18,22 @@ import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { createClient, IAuthDict, MatrixClient } from "../../../src";
|
||||
import { mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { AuthDict, createClient, CryptoEvent, MatrixClient } from "../../../src";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import { encryptAES } from "../../../src/crypto/aes";
|
||||
import { CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
|
||||
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import {
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
} from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -39,8 +52,32 @@ const TEST_DEVICE_ID = "xzcvb";
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
|
||||
// backend. Once we drop support for legacy crypto, it will go away.
|
||||
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
let syncResponder: ISyncResponder;
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
// Encryption key used to encrypt cross signing keys
|
||||
const encryptionKey = new Uint8Array(32);
|
||||
|
||||
/**
|
||||
* Create the {@link CryptoCallbacks}
|
||||
*/
|
||||
function createCryptoCallbacks(): CryptoCallbacks {
|
||||
return {
|
||||
getSecretStorageKey: (keys, name) => {
|
||||
return Promise.resolve<[string, Uint8Array]>(["key_id", encryptionKey]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
@@ -52,8 +89,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
});
|
||||
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
@@ -68,7 +111,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
* @param authDict - The parameters to as the `auth` dict in the key upload request.
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
async function bootstrapCrossSigning(authDict: IAuthDict): Promise<void> {
|
||||
async function bootstrapCrossSigning(authDict: AuthDict): Promise<void> {
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
|
||||
});
|
||||
@@ -105,6 +148,94 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
|
||||
newBackendOnly("get cross signing keys from secret storage and import them", async () => {
|
||||
// Return public cross signing keys
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
|
||||
// Encrypt the private keys and return them in the /sync response as if they are in Secret Storage
|
||||
const masterKey = await encryptAES(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
const selfSigningKey = await encryptAES(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.self_signing",
|
||||
);
|
||||
const userSigningKey = await encryptAES(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.user_signing",
|
||||
);
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.cross_signing.master",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: masterKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.self_signing",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: selfSigningKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.user_signing",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: userSigningKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.secret_storage.key.key_id",
|
||||
content: {
|
||||
key: "key_id",
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
|
||||
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||
);
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// Check if the UserTrustStatusChanged event was fired
|
||||
expect(await userTrustStatusChangedPromise).toBe(aliceClient.getUserId());
|
||||
|
||||
// Expect the signature to be uploaded
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// the device should have a signature with the public self cross signing keys.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus()", () => {
|
||||
@@ -157,4 +288,55 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
expect(isCrossSigningReady).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningKeyId", () => {
|
||||
/**
|
||||
* Intercept /keys/device_signing/upload request and return the cross signing keys
|
||||
* https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3keysdevice_signingupload
|
||||
*
|
||||
* @returns the cross signing keys
|
||||
*/
|
||||
function awaitCrossSigningKeysUpload() {
|
||||
return new Promise<any>((resolve) => {
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
(url, options) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
// Override the routes define in `mockSetupCrossSigningRequests`
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("should return the cross signing key id for each cross signing key", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// Intercept cross signing keys upload
|
||||
const crossSigningKeysPromise = awaitCrossSigningKeysUpload();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
// Get the cross signing keys
|
||||
const crossSigningKeys = await crossSigningKeysPromise;
|
||||
|
||||
const getPubKey = (crossSigningKey: any) => Object.values(crossSigningKey!.keys)[0];
|
||||
|
||||
const masterKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId();
|
||||
expect(masterKeyId).toBe(getPubKey(crossSigningKeys.master_key));
|
||||
|
||||
const selfSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.SelfSigning);
|
||||
expect(selfSigningKeyId).toBe(getPubKey(crossSigningKeys.self_signing_key));
|
||||
|
||||
const userSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.UserSigning);
|
||||
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,22 +20,23 @@ import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { MockResponse, MockResponseFunction } from "fetch-mock";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import type { IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { TEST_ROOM_ID, TEST_ROOM_ID as ROOM_ID, TEST_USER_ID } from "../../test-utils/test-data";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
Category,
|
||||
createClient,
|
||||
IClaimOTKsResult,
|
||||
IContent,
|
||||
IDownloadKeyResult,
|
||||
IEvent,
|
||||
IJoinedRoom,
|
||||
IndexedDBCryptoStore,
|
||||
IStartClientOpts,
|
||||
ISyncResponse,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomStateEvent,
|
||||
IRoomEvent,
|
||||
} from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
@@ -53,8 +55,7 @@ import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { CryptoCallbacks } from "../../../src/crypto-api";
|
||||
|
||||
const ROOM_ID = "!room:id";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -156,18 +157,23 @@ function encryptMegolmEvent(opts: {
|
||||
expect(opts.room_id).toBeTruthy();
|
||||
plaintext.room_id = opts.room_id;
|
||||
}
|
||||
return encryptMegolmEventRawPlainText({ senderKey: opts.senderKey, groupSession: opts.groupSession, plaintext });
|
||||
return encryptMegolmEventRawPlainText({
|
||||
senderKey: opts.senderKey,
|
||||
groupSession: opts.groupSession,
|
||||
plaintext,
|
||||
});
|
||||
}
|
||||
|
||||
function encryptMegolmEventRawPlainText(opts: {
|
||||
senderKey: string;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
plaintext: Partial<IEvent>;
|
||||
origin_server_ts?: number;
|
||||
}): IEvent {
|
||||
return {
|
||||
event_id: "$test_megolm_event_" + Math.random(),
|
||||
sender: "@not_the_real_sender:example.com",
|
||||
origin_server_ts: 1672944778000,
|
||||
sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com",
|
||||
origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000,
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)),
|
||||
@@ -213,55 +219,6 @@ function encryptGroupSessionKey(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
// get a /sync response which contains a single room (ROOM_ID), with the members given
|
||||
function getSyncResponse(roomMembers: string[]): ISyncResponse {
|
||||
const roomResponse: IJoinedRoom = {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
"m.joined_member_count": roomMembers.length,
|
||||
"m.invited_member_count": roomMembers.length,
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
testUtils.mkEventCustom({
|
||||
sender: roomMembers[0],
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
events: [],
|
||||
prev_batch: "",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
};
|
||||
|
||||
for (let i = 0; i < roomMembers.length; i++) {
|
||||
roomResponse.state.events.push(
|
||||
testUtils.mkMembershipCustom({
|
||||
membership: "join",
|
||||
sender: roomMembers[i],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
next_batch: "1",
|
||||
rooms: {
|
||||
join: { [ROOM_ID]: roomResponse },
|
||||
invite: {},
|
||||
leave: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an Olm Session with the test user
|
||||
*
|
||||
@@ -415,7 +372,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
|
||||
let keyReceiver: IE2EKeyReceiver;
|
||||
let keyReceiver: E2EKeyReceiver;
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let keyResponder: E2EKeyResponder;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
let syncResponder: ISyncResponder;
|
||||
@@ -586,6 +546,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -708,6 +672,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.isDecryptionFailure()).toBeFalsy();
|
||||
expect(event.getContent().body).toEqual("42");
|
||||
});
|
||||
|
||||
@@ -1980,7 +1945,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
};
|
||||
};
|
||||
|
||||
for (const path of ["/_matrix/client/r0/keys/upload", "/_matrix/client/v3/keys/upload"]) {
|
||||
for (const path of ["/_matrix/client/v3/keys/upload", "/_matrix/client/v3/keys/upload"]) {
|
||||
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener, {
|
||||
// These routes are already defined in the E2EKeyReceiver
|
||||
// We want to overwrite the behaviour of the E2EKeyReceiver
|
||||
@@ -2117,9 +2082,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
return queryResponseBody;
|
||||
};
|
||||
|
||||
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
|
||||
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
|
||||
}
|
||||
fetchMock.post(
|
||||
new URL("/_matrix/client/v3/keys/query", aliceClient.getHomeserverUrl()).toString(),
|
||||
listener,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2210,7 +2176,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)`
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)`
|
||||
* Resolved when a key is uploaded (ie in `body.content.key`)
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
@@ -2219,7 +2185,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
// This url is called multiple times during the secret storage bootstrap process
|
||||
// When we received the newly generated key, we return it
|
||||
fetchMock.put(
|
||||
"express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)",
|
||||
"express:/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)",
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
|
||||
@@ -2235,7 +2201,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Resolved when the cross signing key is uploaded
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
@@ -2243,7 +2209,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content.encrypted);
|
||||
@@ -2411,4 +2377,193 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(selfSigningKey[secretStorageKey]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Incoming verification in a DM", () => {
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
|
||||
keyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
|
||||
keyResponder.addKeyReceiver(TEST_USER_ID, keyReceiver);
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* Return a verification request event from Bob
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest
|
||||
*/
|
||||
function createVerificationRequestEvent(): IRoomEvent {
|
||||
return {
|
||||
content: {
|
||||
body: "Verification request from Bob to Alice",
|
||||
from_device: "BobDevice",
|
||||
methods: ["m.sas.v1"],
|
||||
msgtype: "m.key.verification.request",
|
||||
to: aliceClient.getUserId()!,
|
||||
},
|
||||
event_id: "$143273582443PhrSn:example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
room_id: TEST_ROOM_ID,
|
||||
sender: "@bob:xyz",
|
||||
type: "m.room.message",
|
||||
unsigned: {
|
||||
age: 1234,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a to-device event
|
||||
* @param groupSession
|
||||
* @param p2pSession
|
||||
*/
|
||||
function createToDeviceEvent(groupSession: Olm.OutboundGroupSession, p2pSession: Olm.Session): Partial<IEvent> {
|
||||
return encryptGroupSessionKey({
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: keyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: keyReceiver.getSigningKey(),
|
||||
olmAccount: testOlmAccount,
|
||||
p2pSession: p2pSession,
|
||||
groupSession: groupSession,
|
||||
room_id: ROOM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and encrypt a verification request event
|
||||
* @param groupSession
|
||||
*/
|
||||
function createEncryptedMessage(groupSession: Olm.OutboundGroupSession): IEvent {
|
||||
return encryptMegolmEvent({
|
||||
senderKey: testSenderKey,
|
||||
groupSession: groupSession,
|
||||
room_id: ROOM_ID,
|
||||
plaintext: createVerificationRequestEvent(),
|
||||
});
|
||||
}
|
||||
|
||||
newBackendOnly("Verification request from Bob to Alice", async () => {
|
||||
// Tell alice she is sharing a room with bob
|
||||
const syncResponse = getSyncResponse(["@bob:xyz"]);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
syncResponse.rooms[Category.Join][TEST_ROOM_ID].timeline.events.push(createVerificationRequestEvent());
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
newBackendOnly("Verification request not found", async () => {
|
||||
// Tell alice she is sharing a room with bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Expect to not find any verification request
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
expect(request).not.toBeDefined();
|
||||
});
|
||||
|
||||
newBackendOnly("Process encrypted verification request", async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } },
|
||||
});
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await testUtils.awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
// Send the Bob's keys
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 2,
|
||||
to_device: {
|
||||
events: [toDeviceEvent],
|
||||
},
|
||||
});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Wait for the message to be decrypted
|
||||
await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"If Bob keys are not received in the 5mins after the verification request, the request is ignored",
|
||||
async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession);
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } },
|
||||
});
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await testUtils.awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
// Advance time by 5mins, the verification request should be ignored after that
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
|
||||
// Send the Bob's keys
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 2,
|
||||
to_device: {
|
||||
events: [toDeviceEvent],
|
||||
},
|
||||
});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Wait for the message to be decrypted
|
||||
await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// the request should not be present
|
||||
expect(request).not.toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,26 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Account } from "@matrix-org/olm";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { logger } from "../../../src/logger";
|
||||
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
|
||||
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { IEvent } from "../../../src";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
|
||||
const ROOM_ID = "!ROOM:ID";
|
||||
|
||||
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
|
||||
const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
|
||||
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
|
||||
|
||||
const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
@@ -65,62 +74,122 @@ const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
|
||||
},
|
||||
};
|
||||
|
||||
const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
/**
|
||||
* start an Olm session with a given recipient
|
||||
*/
|
||||
function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise<Olm.Session> {
|
||||
return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => {
|
||||
const otkId = Object.keys(keys)[0];
|
||||
const otk = keys[otkId];
|
||||
|
||||
const session = new global.Olm.Session();
|
||||
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
|
||||
return session;
|
||||
});
|
||||
enum MockKeyUploadEvent {
|
||||
KeyUploaded = "KeyUploaded",
|
||||
}
|
||||
|
||||
describe("megolm key backups", function () {
|
||||
if (!global.Olm) {
|
||||
logger.warn("not running megolm tests: Olm not present");
|
||||
return;
|
||||
type MockKeyUploadEventHandlerMap = {
|
||||
[MockKeyUploadEvent.KeyUploaded]: (roomId: string, sessionId: string, backupVersion: string) => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Test helper. Returns an event emitter that will emit an event every time fetchmock sees a request to backup a key.
|
||||
*/
|
||||
function mockUploadEmitter(
|
||||
expectedVersion: string,
|
||||
): TypedEventEmitter<MockKeyUploadEvent, MockKeyUploadEventHandlerMap> {
|
||||
const emitter = new TypedEventEmitter();
|
||||
fetchMock.put(
|
||||
"path:/_matrix/client/v3/room_keys/keys",
|
||||
(url, request) => {
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version != expectedVersion) {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: expectedVersion,
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const uploadPayload: IKeyBackup = JSON.parse(request.body?.toString() ?? "{}");
|
||||
let count = 0;
|
||||
for (const [roomId, value] of Object.entries(uploadPayload.rooms)) {
|
||||
for (const sessionId of Object.keys(value.sessions)) {
|
||||
emitter.emit(MockKeyUploadEvent.KeyUploaded, roomId, sessionId, version);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
count: count,
|
||||
etag: "abcdefg",
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
/** an object which intercepts `/sync` requests on the test homeserver */
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
mockInitialApiRequests(TEST_HOMESERVER_URL);
|
||||
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
|
||||
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (aliceClient !== undefined) {
|
||||
await aliceClient.stopClient();
|
||||
}
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
...opts,
|
||||
});
|
||||
await initCrypto(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
const Olm = global.Olm;
|
||||
let testOlmAccount: Olm.Account;
|
||||
let aliceTestClient: TestClient;
|
||||
|
||||
const setupTestClient = (): [Account, TestClient] => {
|
||||
const aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs");
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount!.create();
|
||||
|
||||
return [testOlmAccount, aliceTestClient];
|
||||
};
|
||||
|
||||
beforeAll(function () {
|
||||
return Olm.init();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
[testOlmAccount, aliceTestClient] = setupTestClient();
|
||||
await aliceTestClient!.client.initCrypto();
|
||||
aliceTestClient!.client.crypto!.backupManager.backupInfo = CURVE25519_BACKUP_INFO;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
return aliceTestClient!.stop();
|
||||
});
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", function () {
|
||||
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
@@ -134,37 +203,523 @@ describe("megolm key backups", function () {
|
||||
},
|
||||
};
|
||||
|
||||
return aliceTestClient!
|
||||
.start()
|
||||
.then(() => {
|
||||
return createOlmSession(testOlmAccount, aliceTestClient);
|
||||
})
|
||||
.then(() => {
|
||||
const privkey = decodeRecoveryKey(RECOVERY_KEY);
|
||||
return aliceTestClient!.client!.crypto!.storeSessionBackupPrivateKey(privkey);
|
||||
})
|
||||
.then(() => {
|
||||
aliceTestClient!.httpBackend.when("GET", "/sync").respond(200, syncResponse);
|
||||
aliceTestClient!.expectKeyBackupQuery(ROOM_ID, SESSION_ID, 200, CURVE25519_KEY_BACKUP_DATA);
|
||||
return aliceTestClient!.httpBackend.flushAllExpected();
|
||||
})
|
||||
.then(function (): Promise<MatrixEvent> {
|
||||
const room = aliceTestClient!.client.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
if (event.getContent()) {
|
||||
return Promise.resolve(event);
|
||||
}
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev) => {
|
||||
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
|
||||
resolve(ev);
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.checkKeyBackup();
|
||||
|
||||
// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent()).toEqual("testytest");
|
||||
});
|
||||
|
||||
describe("backupLoop", () => {
|
||||
it("Alice should upload known keys when backup is enabled", async function () {
|
||||
// 404 means that there is no active backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
// check that signalling is working
|
||||
const remainingZeroPromise = new Promise<void>((resolve, reject) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
||||
if (remaining == 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
|
||||
const uploadMockEmitter = mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
|
||||
|
||||
const uploadPromises = someRoomKeys.map((data) => {
|
||||
new Promise<void>((resolve) => {
|
||||
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
||||
if (
|
||||
data.room_id == roomId &&
|
||||
data.session_id == sessionId &&
|
||||
version == testData.SIGNED_BACKUP_DATA.version
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.then((event) => {
|
||||
expect(event.getContent()).toEqual("testytest");
|
||||
});
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
await aliceCrypto.importRoomKeys(someRoomKeys);
|
||||
|
||||
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
|
||||
jest.runAllTimers();
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Wait until all keys are backed up to ensure that when a new key is received the loop is restarted
|
||||
await remainingZeroPromise;
|
||||
|
||||
// A new key import should trigger a new upload.
|
||||
const newKey = testData.MEGOLM_SESSION_DATA;
|
||||
|
||||
const newKeyUploadPromise = new Promise<void>((resolve) => {
|
||||
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
||||
if (
|
||||
newKey.room_id == roomId &&
|
||||
newKey.session_id == sessionId &&
|
||||
version == testData.SIGNED_BACKUP_DATA.version
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await aliceCrypto.importRoomKeys([newKey]);
|
||||
|
||||
jest.runAllTimers();
|
||||
await newKeyUploadPromise;
|
||||
});
|
||||
|
||||
it("Alice should re-upload all keys if a new trusted backup is available", async function () {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
// check that signalling is working
|
||||
const remainingZeroPromise = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
||||
if (remaining == 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
mockUploadEmitter(testData.SIGNED_BACKUP_DATA.version!);
|
||||
await aliceCrypto.importRoomKeys(someRoomKeys);
|
||||
|
||||
// The backup loop is waiting a random amount of time to avoid different clients firing at the same time.
|
||||
jest.runAllTimers();
|
||||
|
||||
// wait for all keys to be backed up
|
||||
await remainingZeroPromise;
|
||||
|
||||
const newBackupVersion = "2";
|
||||
const uploadMockEmitter = mockUploadEmitter(newBackupVersion);
|
||||
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
newBackup.version = newBackupVersion;
|
||||
|
||||
// Let's simulate that a new backup is available by returning error code on key upload
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// If we import a new key the loop will try to upload to old version, it will
|
||||
// fail then check the current version and switch if trusted
|
||||
const uploadPromises = someRoomKeys.map((data) => {
|
||||
new Promise<void>((resolve) => {
|
||||
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
||||
if (data.room_id == roomId && data.session_id == sessionId && version == newBackupVersion) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const disableOldBackup = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupFailed, (errCode) => {
|
||||
if (errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const enableNewBackup = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A new key import should trigger a new upload.
|
||||
const newKey = testData.MEGOLM_SESSION_DATA;
|
||||
|
||||
const newKeyUploadPromise = new Promise<void>((resolve) => {
|
||||
uploadMockEmitter.on(MockKeyUploadEvent.KeyUploaded, (roomId, sessionId, version) => {
|
||||
if (newKey.room_id == roomId && newKey.session_id == sessionId && version == newBackupVersion) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await aliceCrypto.importRoomKeys([newKey]);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
await disableOldBackup;
|
||||
await enableNewBackup;
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
await newKeyUploadPromise;
|
||||
});
|
||||
|
||||
it("Backup loop should be resistant to network failures", async function () {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// on the first key upload attempt, simulate a network failure
|
||||
const failurePromise = new Promise((resolve) => {
|
||||
fetchMock.put(
|
||||
"path:/_matrix/client/v3/room_keys/keys",
|
||||
() => {
|
||||
resolve(undefined);
|
||||
throw new TypeError(`Failed to fetch`);
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// kick the import loop off and wait for the failed request
|
||||
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
await aliceCrypto.importRoomKeys(someRoomKeys);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
jest.runAllTimers();
|
||||
await failurePromise;
|
||||
|
||||
// Fix the endpoint to do successful uploads
|
||||
const successPromise = new Promise((resolve) => {
|
||||
fetchMock.put(
|
||||
"path:/_matrix/client/v3/room_keys/keys",
|
||||
() => {
|
||||
resolve(undefined);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
count: 2,
|
||||
etag: "abcdefg",
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// check that a `KeyBackupSessionsRemaining` event is emitted with `remaining == 0`
|
||||
const allKeysUploadedPromise = new Promise((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupSessionsRemaining, (remaining) => {
|
||||
if (remaining == 0) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// run the timers, which will make the backup loop redo the request
|
||||
await jest.runAllTimersAsync();
|
||||
await successPromise;
|
||||
await allKeysUploadedPromise;
|
||||
});
|
||||
});
|
||||
|
||||
it("getActiveSessionBackupVersion() should give correct result", async function () {
|
||||
// 404 means that there is no active backup
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
// At this point there is no backup
|
||||
let backupStatus: string | null;
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toBeNull();
|
||||
|
||||
// Serve a backup with no trusted signature
|
||||
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
delete unsignedBackup.auth_data.signatures;
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", unsignedBackup, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
const checked = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version);
|
||||
expect(checked?.trustInfo?.trusted).toBeFalsy();
|
||||
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toBeNull();
|
||||
|
||||
// Add a valid signature to the backup
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// check that signalling is working
|
||||
const backupPromise = new Promise<void>((resolve, reject) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const validCheck = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(validCheck?.trustInfo?.trusted).toStrictEqual(true);
|
||||
|
||||
await backupPromise;
|
||||
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
});
|
||||
|
||||
describe("isKeyBackupTrusted", () => {
|
||||
it("does not trust a backup signed by an untrusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// download the device list, to match the trusted case
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
||||
});
|
||||
|
||||
it("trusts a backup signed by a trusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: true, matchesDecryptionKey: false });
|
||||
});
|
||||
|
||||
it("recognises a backup which matches the decryption key", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: true });
|
||||
});
|
||||
|
||||
it("is not fooled by a backup which matches the decryption key but uses a different algorithm", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
);
|
||||
|
||||
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
backup.algorithm = "m.megolm_backup.v1.aes-hmac-sha2";
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(backup);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkKeyBackupAndEnable", () => {
|
||||
it("enables a backup signed by a trusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.trustInfo).toEqual({ trusted: true, matchesDecryptionKey: false });
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
});
|
||||
|
||||
it("does not enable a backup signed by an untrusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// download the device list, to match the trusted case
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.trustInfo).toEqual({ trusted: false, matchesDecryptionKey: false });
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("disables backup when a new untrusted backup is available", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
|
||||
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
delete unsignedBackup.auth_data.signatures;
|
||||
unsignedBackup.version = "2";
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", unsignedBackup, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("switches backup when a new trusted backup is available", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
|
||||
const newBackupVersion = "2";
|
||||
const newBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
newBackup.version = newBackupVersion;
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(newBackupVersion);
|
||||
});
|
||||
|
||||
it("Disables when backup is deleted", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
|
||||
fetchMock.get(
|
||||
"path:/_matrix/client/v3/room_keys/version",
|
||||
{
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No backup found",
|
||||
},
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
const noResult = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(noResult).toBeNull();
|
||||
expect(await aliceCrypto.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** make sure that the client knows about the dummy device */
|
||||
async function waitForDeviceList(): Promise<void> {
|
||||
// Completing the initial sync will make the device list download outdated device lists (of which our own
|
||||
// user will be one).
|
||||
syncResponder.sendOrQueueSyncResponse({});
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The client should now know about the dummy device
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,7 +22,15 @@ import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src";
|
||||
import {
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
IContent,
|
||||
ICreateClientOpts,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
} from "../../../src";
|
||||
import {
|
||||
canAcceptVerificationRequest,
|
||||
ShowQrCodeCallbacks,
|
||||
@@ -34,15 +42,20 @@ import {
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, emitPromise, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
BOB_SIGNED_TEST_DEVICE_DATA,
|
||||
BOB_TEST_USER_ID,
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
TEST_DEVICE_ID,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
TEST_ROOM_ID,
|
||||
TEST_USER_ID,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
} from "../../test-utils/test-data";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
@@ -59,7 +72,7 @@ beforeAll(async () => {
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
beforeAll(async () => {
|
||||
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-js");
|
||||
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
@@ -81,9 +94,9 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
*/
|
||||
// we test with both crypto stacks...
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
|
||||
// backend. Once we drop support for legacy crypto, it will go away.
|
||||
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
|
||||
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
@@ -124,7 +137,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
describe("Outgoing verification requests for another device", () => {
|
||||
beforeEach(async () => {
|
||||
// pretend that we have another device, which we will verify
|
||||
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
});
|
||||
|
||||
// test with (1) the default verification method list, (2) a custom verification method list.
|
||||
@@ -164,6 +177,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(requests[0].transactionId).toEqual(transactionId);
|
||||
}
|
||||
|
||||
// check that the returned request depends on the given userID
|
||||
{
|
||||
const requests = aliceClient
|
||||
.getCrypto()!
|
||||
.getVerificationRequestsToDeviceInProgress("@unknown:localhost");
|
||||
expect(requests.length).toEqual(0);
|
||||
}
|
||||
|
||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
@@ -387,12 +408,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
});
|
||||
|
||||
oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => {
|
||||
it("can verify another via QR code with an untrusted cross-signing key", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy();
|
||||
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
const [requestBody, request] = await Promise.all([
|
||||
@@ -414,7 +434,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// we should now have QR data we can display
|
||||
const qrCodeBuffer = request.getQRCodeBytes()!;
|
||||
const qrCodeBuffer = (await request.generateQRCode())!;
|
||||
expect(qrCodeBuffer).toBeTruthy();
|
||||
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
@@ -430,16 +450,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
);
|
||||
const sharedSecret = qrCodeBuffer.subarray(74 + txnIdLen);
|
||||
|
||||
// we should still be "Ready" and have no verifier
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.verifier).toBeUndefined();
|
||||
|
||||
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
method: "m.reciprocate.v1",
|
||||
transaction_id: transactionId,
|
||||
secret: encodeUnpaddedBase64(sharedSecret),
|
||||
},
|
||||
});
|
||||
returnToDeviceMessageFromSync(buildReciprocateStartMessage(transactionId, sharedSecret));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
|
||||
@@ -447,34 +463,184 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
// there should now be a verifier
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// ... which we call .verify on, which emits a ShowReciprocateQr event
|
||||
const verificationPromise = verifier.verify();
|
||||
const reciprocateQRCodeCallbacks = await new Promise<ShowQrCodeCallbacks>((resolve) => {
|
||||
const reciprocatePromise = new Promise<ShowQrCodeCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowReciprocateQr, resolve);
|
||||
});
|
||||
const verificationPromise = verifier.verify();
|
||||
const reciprocateQRCodeCallbacks = await reciprocatePromise;
|
||||
|
||||
// getReciprocateQrCodeCallbacks() is an alternative way to get the callbacks
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBe(reciprocateQRCodeCallbacks);
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
|
||||
// Alice confirms she is happy
|
||||
// Alice confirms she is happy, which makes her reply with a 'done'
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.done");
|
||||
reciprocateQRCodeCallbacks.confirm();
|
||||
await sendToDevicePromise;
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
// the dummy device replies with its own 'done'
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
});
|
||||
|
||||
newBackendOnly("can verify another by scanning their QR code", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// we need cross-signing keys for a QR code verification
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can show a QR code
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.otherPartySupportsMethod("m.qr_code.show.v1")).toBe(true);
|
||||
|
||||
// the dummy device shows a QR code
|
||||
const sharedSecret = "SUPERSEKRET";
|
||||
const qrCodeBuffer = buildQRCode(
|
||||
transactionId,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
sharedSecret,
|
||||
);
|
||||
|
||||
// Alice scans the QR code
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
const verifier = await request.scanQRCode(qrCodeBuffer);
|
||||
|
||||
const requestBody = await sendToDevicePromise;
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage).toEqual({
|
||||
from_device: aliceClient.deviceId,
|
||||
method: "m.reciprocate.v1",
|
||||
transaction_id: transactionId,
|
||||
secret: encodeUnpaddedBase64(Buffer.from(sharedSecret)),
|
||||
});
|
||||
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
const verificationPromise = verifier.verify();
|
||||
|
||||
// the dummy device confirms that Alice scanned the QR code, by replying with a done
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// Alice also replies with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
});
|
||||
|
||||
it("can send an SAS start after QR code display", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, with an indication it can scan a QR code
|
||||
// or do the emoji dance
|
||||
returnToDeviceMessageFromSync(
|
||||
buildReadyMessage(transactionId, ["m.qr_code.scan.v1", "m.sas.v1", "m.reciprocate.v1"]),
|
||||
);
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// Alice displays the QR code
|
||||
const qrCodeBuffer = (await request.generateQRCode())!;
|
||||
expect(qrCodeBuffer).toBeTruthy();
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.verifier).toBeUndefined();
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// ... but Alice wants to do an SAS verification
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
await request.startVerification("m.sas.v1");
|
||||
await sendToDevicePromise;
|
||||
|
||||
// There should now be a `verifier`
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
// clean up the test
|
||||
expectSendToDeviceMessage("m.key.verification.cancel");
|
||||
request.cancel();
|
||||
await expect(verifier.verify()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("can receive an SAS start after QR code display", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, with an indication it can scan a QR code
|
||||
// or do the emoji dance
|
||||
returnToDeviceMessageFromSync(
|
||||
buildReadyMessage(transactionId, ["m.qr_code.scan.v1", "m.sas.v1", "m.reciprocate.v1"]),
|
||||
);
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// Alice displays the QR code
|
||||
const qrCodeBuffer = (await request.generateQRCode())!;
|
||||
expect(qrCodeBuffer).toBeTruthy();
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.verifier).toBeUndefined();
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// ... but the dummy device wants to do an SAS verification
|
||||
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
|
||||
await emitPromise(request, VerificationRequestEvent.Change);
|
||||
|
||||
// Alice should now have a `verifier`
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
// clean up the test
|
||||
expectSendToDeviceMessage("m.key.verification.cancel");
|
||||
request.cancel();
|
||||
await expect(verifier.verify()).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancellation", () => {
|
||||
beforeEach(async () => {
|
||||
// pretend that we have another device, which we will start verifying
|
||||
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
await waitForDeviceList();
|
||||
@@ -542,11 +708,55 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(request.phase).toEqual(VerificationPhase.Cancelled);
|
||||
expect(verifier.hasBeenCancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("can cancel in the ShowQrCodeCallbacks", async () => {
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, with an indication it can scan the QR code
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.scan.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// we should now have QR data we can display
|
||||
const qrCodeBuffer = (await request.generateQRCode())!;
|
||||
expect(qrCodeBuffer).toBeTruthy();
|
||||
const sharedSecret = qrCodeBuffer.subarray(74 + transactionId.length);
|
||||
|
||||
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
|
||||
returnToDeviceMessageFromSync(buildReciprocateStartMessage(transactionId, sharedSecret));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
|
||||
|
||||
// there should now be a verifier
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
|
||||
// ... which we call .verify on, which emits a ShowReciprocateQr event
|
||||
const reciprocatePromise = emitPromise(verifier, VerifierEvent.ShowReciprocateQr);
|
||||
const verificationPromise = verifier.verify();
|
||||
const reciprocateQRCodeCallbacks: ShowQrCodeCallbacks = await reciprocatePromise;
|
||||
|
||||
// Alice complains that she didn't see the dummy device scan her code
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.cancel");
|
||||
reciprocateQRCodeCallbacks.cancel();
|
||||
await sendToDevicePromise;
|
||||
|
||||
// ... which should cancel the verifier
|
||||
await expect(verificationPromise).rejects.toBeTruthy();
|
||||
expect(request.phase).toEqual(VerificationPhase.Cancelled);
|
||||
expect(verifier.hasBeenCancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Incoming verification from another device", () => {
|
||||
beforeEach(async () => {
|
||||
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
await waitForDeviceList();
|
||||
@@ -611,6 +821,82 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
});
|
||||
|
||||
describe("Send verification request in DM", () => {
|
||||
beforeEach(async () => {
|
||||
aliceClient = await startTestClient();
|
||||
aliceClient.setGlobalErrorOnUnknownDevices(false);
|
||||
|
||||
e2eKeyResponder.addCrossSigningData(BOB_SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
e2eKeyResponder.addDeviceKeys(BOB_SIGNED_TEST_DEVICE_DATA);
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID]));
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock to respond when the verification request is sent
|
||||
* Handle both encrypted and unencrypted requests
|
||||
*/
|
||||
function awaitRoomMessageRequest(): Promise<IContent> {
|
||||
return new Promise((resolve) => {
|
||||
// Case of unencrypted message of the new crypto
|
||||
fetchMock.put(
|
||||
"express:/_matrix/client/v3/rooms/:roomId/send/m.room.message/:txId",
|
||||
(url: string, options: RequestInit) => {
|
||||
resolve(JSON.parse(options.body as string));
|
||||
return { event_id: "$YUwRidLecu:example.com" };
|
||||
},
|
||||
);
|
||||
|
||||
// Case of encrypted message of the old crypto
|
||||
fetchMock.put(
|
||||
"express:/_matrix/client/v3/rooms/:roomId/send/m.room.encrypted/:txId",
|
||||
async (url: string, options: RequestInit) => {
|
||||
const encryptedMessage = JSON.parse(options.body as string);
|
||||
const event = new MatrixEvent({
|
||||
content: encryptedMessage,
|
||||
type: "m.room.encrypted",
|
||||
room_id: TEST_ROOM_ID,
|
||||
});
|
||||
// Try to decrypt the event
|
||||
event.once(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, error?: Error) => {
|
||||
expect(error).not.toBeDefined();
|
||||
resolve(decryptedEvent.getContent());
|
||||
});
|
||||
await aliceClient.decryptEventIfNeeded(event);
|
||||
return { event_id: "$YUwRidLecu:example.com" };
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("alice sends a verification request in a DM to bob", async () => {
|
||||
fetchMock.post("express:/_matrix/client/v3/keys/claim", () => ({ one_time_keys: BOB_ONE_TIME_KEYS }));
|
||||
|
||||
// In `DeviceList#doQueuedQueries`, the key download response is processed every 5ms
|
||||
// 5ms by users, ie Bob and Alice
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
const messageRequestPromise = awaitRoomMessageRequest();
|
||||
const verificationRequest = await aliceClient
|
||||
.getCrypto()!
|
||||
.requestVerificationDM(BOB_TEST_USER_ID, TEST_ROOM_ID);
|
||||
const requestContent = await messageRequestPromise;
|
||||
|
||||
expect(requestContent.from_device).toBe(aliceClient.getDeviceId());
|
||||
expect(requestContent.methods.sort()).toStrictEqual(
|
||||
["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"].sort(),
|
||||
);
|
||||
expect(requestContent.msgtype).toBe("m.key.verification.request");
|
||||
expect(requestContent.to).toBe(BOB_TEST_USER_ID);
|
||||
|
||||
expect(verificationRequest.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(verificationRequest.isSelfVerification).toBe(false);
|
||||
expect(verificationRequest.otherUserId).toBe(BOB_TEST_USER_ID);
|
||||
});
|
||||
});
|
||||
|
||||
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
@@ -717,6 +1003,19 @@ function buildReadyMessage(transactionId: string, methods: string[]): { type: st
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: Uint8Array) {
|
||||
return {
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
method: "m.reciprocate.v1",
|
||||
transaction_id: transactionId,
|
||||
secret: encodeUnpaddedBase64(sharedSecret),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.start to-device message suitable for the SAS flow, originating from the dummy device */
|
||||
function buildSasStartMessage(transactionId: string): { type: string; content: object } {
|
||||
return {
|
||||
@@ -794,3 +1093,22 @@ function buildDoneMessage(transactionId: string) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array {
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
|
||||
const qrCodeBuffer = Buffer.alloc(150); // oversize
|
||||
let idx = 0;
|
||||
idx += qrCodeBuffer.write("MATRIX", idx, "ascii");
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // version
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode
|
||||
idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx);
|
||||
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
|
||||
|
||||
idx += Buffer.from(key1Base64, "base64").copy(qrCodeBuffer, idx);
|
||||
idx += Buffer.from(key2Base64, "base64").copy(qrCodeBuffer, idx);
|
||||
idx += qrCodeBuffer.write(sharedSecret, idx);
|
||||
|
||||
// truncate to the right length
|
||||
return qrCodeBuffer.subarray(0, idx);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ function startClient(httpBackend: HttpBackend, client: MatrixClient) {
|
||||
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
|
||||
|
||||
client.startClient();
|
||||
client.startClient({ threadSupport: true });
|
||||
|
||||
// set up a promise which will resolve once the client is initialised
|
||||
const prom = new Promise<void>((resolve) => {
|
||||
@@ -248,7 +248,7 @@ describe("getEventTimeline support", function () {
|
||||
return startClient(httpBackend, client).then(function () {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room!.getTimelineSets()[0];
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,7 +260,18 @@ describe("getEventTimeline support", function () {
|
||||
return startClient(httpBackend, client).then(() => {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room!.getTimelineSets()[0];
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
|
||||
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
|
||||
event: {
|
||||
event_id: "event",
|
||||
},
|
||||
events_after: [],
|
||||
events_before: [],
|
||||
state: [],
|
||||
}));
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,7 +282,7 @@ describe("getEventTimeline support", function () {
|
||||
|
||||
return startClient(httpBackend, client).then(function () {
|
||||
const timelineSet = new EventTimelineSet(undefined);
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -778,7 +789,18 @@ describe("MatrixClient event timelines", function () {
|
||||
return startClient(httpBackend, client).then(() => {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room.getTimelineSets()[0];
|
||||
expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy();
|
||||
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
|
||||
event: {
|
||||
event_id: "event",
|
||||
},
|
||||
events_after: [],
|
||||
events_before: [],
|
||||
state: [],
|
||||
}));
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1262,7 +1284,6 @@ describe("MatrixClient event timelines", function () {
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_ROOT_REACTION.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
@@ -1321,7 +1342,7 @@ describe("MatrixClient event timelines", function () {
|
||||
function respondToContext(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
|
||||
const request = httpBackend.when(
|
||||
"GET",
|
||||
encodeUri("/_matrix/client/r0/rooms/$roomId/context/$eventId", {
|
||||
encodeUri("/_matrix/client/v3/rooms/$roomId/context/$eventId", {
|
||||
$roomId: roomId,
|
||||
$eventId: event.event_id!,
|
||||
}),
|
||||
@@ -1339,7 +1360,7 @@ describe("MatrixClient event timelines", function () {
|
||||
function respondToEvent(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
|
||||
const request = httpBackend.when(
|
||||
"GET",
|
||||
encodeUri("/_matrix/client/r0/rooms/$roomId/event/$eventId", {
|
||||
encodeUri("/_matrix/client/v3/rooms/$roomId/event/$eventId", {
|
||||
$roomId: roomId,
|
||||
$eventId: event.event_id!,
|
||||
}),
|
||||
@@ -1350,7 +1371,7 @@ describe("MatrixClient event timelines", function () {
|
||||
function respondToMessagesRequest(): ExpectedHttpRequest {
|
||||
const request = httpBackend.when(
|
||||
"GET",
|
||||
encodeUri("/_matrix/client/r0/rooms/$roomId/messages", {
|
||||
encodeUri("/_matrix/client/v3/rooms/$roomId/messages", {
|
||||
$roomId: roomId,
|
||||
}),
|
||||
);
|
||||
@@ -2005,6 +2026,25 @@ describe("MatrixClient event timelines", function () {
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [THREAD_REPLY],
|
||||
};
|
||||
});
|
||||
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
|
||||
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import {
|
||||
Room,
|
||||
IndexedDBStore,
|
||||
RelationType,
|
||||
EventType,
|
||||
} from "../../src";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
|
||||
@@ -223,8 +224,6 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
|
||||
it("should honour lazyLoadMembers if user is not a guest", () => {
|
||||
client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true);
|
||||
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
@@ -241,8 +240,6 @@ describe("MatrixClient syncing", () => {
|
||||
it("should not honour lazyLoadMembers if user is a guest", () => {
|
||||
httpBackend!.expectedRequests = [];
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
client!.doesServerSupportLazyLoading = jest.fn().mockResolvedValue(true);
|
||||
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
@@ -1590,6 +1587,68 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply encrypted notification logic for events within the same sync blob", async () => {
|
||||
const roomId = "!room123:server";
|
||||
const syncData = {
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: {
|
||||
ephemeral: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
room: roomId,
|
||||
event: true,
|
||||
skey: "",
|
||||
type: EventType.RoomEncryption,
|
||||
content: {},
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: "hello",
|
||||
}),
|
||||
],
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
user: otherUserId,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
user: selfUserId,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("of a room", () => {
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("SlidingSyncSdk", () => {
|
||||
await client!.initCrypto();
|
||||
syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
|
||||
}
|
||||
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
|
||||
httpBackend!.when("GET", "/_matrix/client/v3/pushrules").respond(200, {});
|
||||
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
|
||||
};
|
||||
|
||||
|
||||
@@ -1161,11 +1161,6 @@ describe("SlidingSync", () => {
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]);
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]);
|
||||
|
||||
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
@@ -1180,8 +1175,12 @@ describe("SlidingSync", () => {
|
||||
txn_id: txnId,
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await okPromise;
|
||||
await Promise.all([
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
okPromise,
|
||||
]);
|
||||
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
@@ -1200,7 +1199,6 @@ describe("SlidingSync", () => {
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]);
|
||||
|
||||
const C = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let pendingC = true;
|
||||
@@ -1217,9 +1215,12 @@ describe("SlidingSync", () => {
|
||||
txn_id: gotTxnIds[1],
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved
|
||||
await Promise.all([
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
|
||||
]);
|
||||
expect(pendingC).toBe(true); // C is pending still
|
||||
});
|
||||
it("should do nothing for unknown txn_ids", async () => {
|
||||
|
||||
@@ -75,8 +75,6 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
const listener = (url: string, options: RequestInit) =>
|
||||
this.onKeyUploadRequest(resolveOneTimeKeys, options);
|
||||
|
||||
// catch both r0 and v3 variants
|
||||
fetchMock.post(new URL("/_matrix/client/r0/keys/upload", homeserverUrl).toString(), listener);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ export class E2EKeyResponder {
|
||||
public constructor(homeserverUrl: string) {
|
||||
// set up a listener for /keys/query.
|
||||
const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options);
|
||||
// catch both r0 and v3 variants
|
||||
fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener);
|
||||
}
|
||||
|
||||
@@ -89,12 +87,10 @@ export class E2EKeyResponder {
|
||||
/**
|
||||
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
|
||||
*
|
||||
* @param userId - user the keys belong to
|
||||
* @param deviceId - device the keys belong to
|
||||
* @param keys - device keys for this device.
|
||||
*/
|
||||
public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) {
|
||||
this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys);
|
||||
public addDeviceKeys(keys: IDeviceKeys) {
|
||||
this.deviceKeysByUserByDevice.getOrCreate(keys.user_id).set(keys.device_id, keys);
|
||||
}
|
||||
|
||||
/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
|
||||
|
||||
@@ -75,7 +75,7 @@ export class SyncResponder implements ISyncResponder {
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
this.debug = debugFunc(`sync-responder:[${homeserverUrl}]`);
|
||||
fetchMock.get("begin:" + new URL("/_matrix/client/r0/sync?", homeserverUrl).toString(), (_url, _options) =>
|
||||
fetchMock.get("begin:" + new URL("/_matrix/client/v3/sync?", homeserverUrl).toString(), (_url, _options) =>
|
||||
this.onSyncRequest(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ export const mockClientMethodsEvents = () => ({
|
||||
* Returns basic mocked client methods related to server support
|
||||
*/
|
||||
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
|
||||
doesServerSupportSeparateAddAndBind: jest.fn(),
|
||||
getIdentityServerUrl: jest.fn(),
|
||||
getHomeserverUrl: jest.fn(),
|
||||
getCapabilities: jest.fn().mockReturnValue({}),
|
||||
|
||||
@@ -22,9 +22,9 @@ import fetchMock from "fetch-mock-jest";
|
||||
* @param homeserverUrl - the homeserver url for the client under test
|
||||
*/
|
||||
export function mockInitialApiRequests(homeserverUrl: string) {
|
||||
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
|
||||
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["v1.1"] });
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.postOnce(new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
filter_id: "fid",
|
||||
});
|
||||
}
|
||||
@@ -32,13 +32,16 @@ export function mockInitialApiRequests(homeserverUrl: string) {
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
|
||||
* Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request
|
||||
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
|
||||
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
|
||||
*/
|
||||
export function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
fetchMock.get("express:/_matrix/client/v3/user/:userId/account_data/:type", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OidcClientConfig } from "../../src";
|
||||
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
|
||||
|
||||
/**
|
||||
* Makes a valid OidcClientConfig with minimum valid values
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns OidcClientConfig
|
||||
*/
|
||||
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
|
||||
const metadata = mockOpenIdConfiguration(issuer);
|
||||
|
||||
return {
|
||||
issuer,
|
||||
account: issuer + "account",
|
||||
registrationEndpoint: metadata.registration_endpoint,
|
||||
authorizationEndpoint: metadata.authorization_endpoint,
|
||||
tokenEndpoint: metadata.token_endpoint,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Useful for mocking <issuer>/.well-known/openid-configuration
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns ValidatedIssuerMetadata
|
||||
*/
|
||||
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
||||
issuer,
|
||||
revocation_endpoint: issuer + "revoke",
|
||||
token_endpoint: issuer + "token",
|
||||
authorization_endpoint: issuer + "auth",
|
||||
registration_endpoint: issuer + "registration",
|
||||
jwks_uri: issuer + "jwks",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
@@ -28,50 +28,45 @@ import base64
|
||||
import json
|
||||
|
||||
from canonicaljson import encode_canonical_json
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from random import randbytes, seed
|
||||
|
||||
# input data
|
||||
TEST_USER_ID = "@alice:localhost"
|
||||
TEST_DEVICE_ID = "test_device"
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
ALICE_DATA = {
|
||||
"TEST_USER_ID": "@alice:localhost",
|
||||
"TEST_DEVICE_ID": "test_device",
|
||||
"TEST_ROOM_ID": "!room:id",
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
|
||||
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"doyouspeakwhaaaaaaaaaaaaaaaaaale",
|
||||
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"useruseruseruseruseruseruseruser",
|
||||
"SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"selfselfselfselfselfselfselfself",
|
||||
|
||||
# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
|
||||
"B64_BACKUP_DECRYPTION_KEY": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=",
|
||||
|
||||
"OTK": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw"
|
||||
}
|
||||
|
||||
BOB_DATA = {
|
||||
"TEST_USER_ID": "@bob:xyz",
|
||||
"TEST_DEVICE_ID": "bob_device",
|
||||
"TEST_ROOM_ID": "!room:id",
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"Deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
|
||||
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Doyouspeakwhaaaaaaaaaaaaaaaaaale",
|
||||
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Useruseruseruseruseruseruseruser",
|
||||
"SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Selfselfselfselfselfselfselfself",
|
||||
|
||||
# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
|
||||
"B64_BACKUP_DECRYPTION_KEY": "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=",
|
||||
|
||||
"OTK": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw"
|
||||
}
|
||||
|
||||
def main() -> None:
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_public_key = encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
device_data = {
|
||||
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
"device_id": TEST_DEVICE_ID,
|
||||
"keys": {
|
||||
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
|
||||
},
|
||||
"signatures": {TEST_USER_ID: {}},
|
||||
"user_id": TEST_USER_ID,
|
||||
}
|
||||
|
||||
device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
|
||||
device_data, private_key
|
||||
)
|
||||
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
print(
|
||||
f"""\
|
||||
/* Test data for cryptography tests
|
||||
@@ -79,42 +74,183 @@ def main() -> None:
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
|
||||
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
import {{ KeyBackupInfo }} from "../../../src/crypto-api";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
export const TEST_USER_ID = "{TEST_USER_ID}";
|
||||
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
|
||||
// Alice data
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
|
||||
{build_test_data(ALICE_DATA)}
|
||||
// Bob data
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
json.dumps(build_cross_signing_keys_data(), indent=4)
|
||||
};
|
||||
{build_test_data(BOB_DATA, "BOB_")}
|
||||
""",
|
||||
end="",
|
||||
)
|
||||
|
||||
# Use static seed to have stable random test data upon new generation
|
||||
seed(10)
|
||||
|
||||
def build_cross_signing_keys_data() -> dict:
|
||||
def build_test_data(user_data, prefix = "") -> str:
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
user_data["TEST_DEVICE_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_public_key = encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
device_data = {
|
||||
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
"device_id": user_data["TEST_DEVICE_ID"],
|
||||
"keys": {
|
||||
f"curve25519:{user_data['TEST_DEVICE_ID']}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
f"ed25519:{user_data['TEST_DEVICE_ID']}": b64_public_key,
|
||||
},
|
||||
"signatures": {user_data['TEST_USER_ID']: {}},
|
||||
"user_id": user_data["TEST_USER_ID"],
|
||||
}
|
||||
|
||||
device_data["signatures"][user_data["TEST_USER_ID"]][f"ed25519:{user_data['TEST_DEVICE_ID']}"] = sign_json(
|
||||
device_data, private_key
|
||||
)
|
||||
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
b64_master_private_key = encode_base64(user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
|
||||
|
||||
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_self_signing_public_key = encode_base64(
|
||||
self_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
b64_self_signing_private_key = encode_base64( user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
|
||||
|
||||
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_user_signing_public_key = encode_base64(
|
||||
user_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
b64_user_signing_private_key = encode_base64(user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"])
|
||||
|
||||
backup_decryption_key = x25519.X25519PrivateKey.from_private_bytes(
|
||||
base64.b64decode(user_data["B64_BACKUP_DECRYPTION_KEY"])
|
||||
)
|
||||
b64_backup_public_key = encode_base64(
|
||||
backup_decryption_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
backup_data = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": b64_backup_public_key,
|
||||
},
|
||||
}
|
||||
# sign with our device key
|
||||
sig = sign_json(backup_data["auth_data"], private_key)
|
||||
backup_data["auth_data"]["signatures"] = {
|
||||
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": sig}
|
||||
}
|
||||
|
||||
set_of_exported_room_keys = [build_exported_megolm_key(), build_exported_megolm_key()]
|
||||
|
||||
additional_exported_room_key = build_exported_megolm_key()
|
||||
|
||||
otk_to_sign = {
|
||||
"key": user_data['OTK']
|
||||
}
|
||||
# sign our public otk key with our device key
|
||||
otk = sign_json(otk_to_sign, private_key)
|
||||
otks = {
|
||||
user_data["TEST_USER_ID"]: {
|
||||
user_data['TEST_DEVICE_ID']: {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": user_data["OTK"],
|
||||
"signatures": {
|
||||
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": otk}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f"""\
|
||||
export const {prefix}TEST_USER_ID = "{user_data['TEST_USER_ID']}";
|
||||
export const {prefix}TEST_DEVICE_ID = "{user_data['TEST_DEVICE_ID']}";
|
||||
export const {prefix}TEST_ROOM_ID = "{user_data['TEST_ROOM_ID']}";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const {prefix}TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const {prefix}SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const {prefix}MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
|
||||
|
||||
/** base64-encoded private master cross-signing key */
|
||||
export const {prefix}MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_master_private_key}";
|
||||
|
||||
/** base64-encoded public self cross-signing key */
|
||||
export const {prefix}SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_self_signing_public_key}";
|
||||
|
||||
/** base64-encoded private self signing cross-signing key */
|
||||
export const {prefix}SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_self_signing_private_key}";
|
||||
|
||||
/** base64-encoded public user cross-signing key */
|
||||
export const {prefix}USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_user_signing_public_key}";
|
||||
|
||||
/** base64-encoded private user signing cross-signing key */
|
||||
export const {prefix}USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_user_signing_private_key}";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
json.dumps(build_cross_signing_keys_data(user_data), indent=4)
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
|
||||
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
|
||||
json.dumps(set_of_exported_room_keys, indent=4)
|
||||
};
|
||||
|
||||
/** An exported megolm session */
|
||||
export const {prefix}MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
json.dumps(additional_exported_room_key, indent=4)
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
"""
|
||||
|
||||
|
||||
def build_cross_signing_keys_data(user_data) -> dict:
|
||||
"""Build the signed cross-signing-keys data for return from /keys/query"""
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
user_data["MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
user_data["SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_self_signing_public_key = encode_base64(
|
||||
self_signing_private_key.public_key().public_bytes(
|
||||
@@ -122,7 +258,7 @@ def build_cross_signing_keys_data() -> dict:
|
||||
)
|
||||
)
|
||||
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
user_data["USER_CROSS_SIGNING_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
b64_user_signing_public_key = encode_base64(
|
||||
user_signing_private_key.public_key().public_bytes(
|
||||
@@ -132,39 +268,39 @@ def build_cross_signing_keys_data() -> dict:
|
||||
# create without signatures initially
|
||||
cross_signing_keys_data = {
|
||||
"master_keys": {
|
||||
TEST_USER_ID: {
|
||||
user_data["TEST_USER_ID"]: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_master_public_key}": b64_master_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"user_id": user_data["TEST_USER_ID"],
|
||||
"usage": ["master"],
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
user_data["TEST_USER_ID"]: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"user_id": user_data["TEST_USER_ID"],
|
||||
"usage": ["self_signing"],
|
||||
},
|
||||
},
|
||||
"user_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
user_data["TEST_USER_ID"]: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"user_id": user_data["TEST_USER_ID"],
|
||||
"usage": ["user_signing"],
|
||||
},
|
||||
},
|
||||
}
|
||||
# sign the sub-keys with the master
|
||||
for k in ["self_signing_keys", "user_signing_keys"]:
|
||||
to_sign = cross_signing_keys_data[k][TEST_USER_ID]
|
||||
to_sign = cross_signing_keys_data[k][user_data["TEST_USER_ID"]]
|
||||
sig = sign_json(to_sign, master_private_key)
|
||||
to_sign["signatures"] = {
|
||||
TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig}
|
||||
user_data["TEST_USER_ID"]: {f"ed25519:{b64_master_public_key}": sig}
|
||||
}
|
||||
|
||||
return cross_signing_keys_data
|
||||
@@ -198,6 +334,39 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
|
||||
return signature_base64
|
||||
|
||||
def build_exported_megolm_key() -> dict:
|
||||
"""
|
||||
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
|
||||
that can be imported via importRoomKeys API.
|
||||
"""
|
||||
index = 0
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32))
|
||||
# Just use radom bytes for the ratchet parts
|
||||
ratchet = randbytes(32 * 4)
|
||||
# exported key, start with version byte
|
||||
exported_key = bytearray(b'\x01')
|
||||
exported_key += index.to_bytes(4, 'big')
|
||||
exported_key += ratchet
|
||||
# KPub
|
||||
exported_key += private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
|
||||
|
||||
megolm_export = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
),
|
||||
"session_key": encode_base64(exported_key),
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
|
||||
},
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
}
|
||||
|
||||
return megolm_export
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import { IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult } from "../../../src";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
// Alice data
|
||||
|
||||
export const TEST_USER_ID = "@alice:localhost";
|
||||
export const TEST_DEVICE_ID = "test_device";
|
||||
export const TEST_ROOM_ID = "!room:id";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
|
||||
@@ -36,6 +40,21 @@ export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY";
|
||||
|
||||
/** base64-encoded private master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "ZG95b3VzcGVha3doYWFhYWFhYWFhYWFhYWFhYWFhbGU";
|
||||
|
||||
/** base64-encoded public self cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY";
|
||||
|
||||
/** base64-encoded private self signing cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "c2VsZnNlbGZzZWxmc2VsZnNlbGZzZWxmc2VsZnNlbGY";
|
||||
|
||||
/** base64-encoded public user cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY";
|
||||
|
||||
/** base64-encoded private user signing cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "dXNlcnVzZXJ1c2VydXNlcnVzZXJ1c2VydXNlcnVzZXI";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
"master_keys": {
|
||||
@@ -82,3 +101,241 @@ export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "mPYSGA2l1tOQiipEDEVYhDSdTSFh2lDW1qpGKYZRxTc",
|
||||
"session_key": "AQAAAAAHwgkB49BTPAEGTCK6degxUIbl8GPG2ugPRYhNtOpNic63u11+baXFfjDw5fmVfD1gJXpQQjGsqrIYioxrB1xzl7mfb942UHhYdaMQZowpp1fSpJVsxR5TddUU2EWifYD9EQsoz8mY1zqoazm4vUP4v9yxaTcUBj2c6HMJCY0gCJj2EhgNpdbTkIoqRAxFWIQ0nU0hYdpQ1taqRimGUcU3",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
}
|
||||
];
|
||||
|
||||
/** An exported megolm session */
|
||||
export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"session_key": "AQAAAABXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIXBlK8tAfJo3cJnlh7F4ltEOAqrdME6dU0zXTkqXmURqYqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const ONE_TIME_KEYS = {
|
||||
"@alice:localhost": {
|
||||
"test_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "25djC6Rk6gIgFBMVawY9X9LnY8XMMziey6lKqL8Q5Bbp7T1vw9uk0RE7eKO2a/jNLcYroO2xRztGhBrKz5sOCQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Bob data
|
||||
|
||||
export const BOB_TEST_USER_ID = "@bob:xyz";
|
||||
export const BOB_TEST_DEVICE_ID = "bob_device";
|
||||
export const BOB_TEST_ROOM_ID = "!room:id";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const BOB_TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "jmY0h8QS6Te6gxyjOmMc0eKOqmbAtXpVo4CCWFubk50";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const BOB_SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
|
||||
"algorithms": [
|
||||
"m.olm.v1.curve25519-aes-sha2",
|
||||
"m.megolm.v1.aes-sha2"
|
||||
],
|
||||
"device_id": "bob_device",
|
||||
"keys": {
|
||||
"curve25519:bob_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
"ed25519:bob_device": "jmY0h8QS6Te6gxyjOmMc0eKOqmbAtXpVo4CCWFubk50"
|
||||
},
|
||||
"user_id": "@bob:xyz",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "4ApBs9jaeGyfdYaWRUdBvQAkDyXjACJ9KJ0xLHMgiFT/1yo6VqPTx2iziKGnrBiGhbtKNxEhDPOvZZkBU73cDQ"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const BOB_MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA";
|
||||
|
||||
/** base64-encoded private master cross-signing key */
|
||||
export const BOB_MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "RG95b3VzcGVha3doYWFhYWFhYWFhYWFhYWFhYWFhbGU";
|
||||
|
||||
/** base64-encoded public self cross-signing key */
|
||||
export const BOB_SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A";
|
||||
|
||||
/** base64-encoded private self signing cross-signing key */
|
||||
export const BOB_SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "U2VsZnNlbGZzZWxmc2VsZnNlbGZzZWxmc2VsZnNlbGY";
|
||||
|
||||
/** base64-encoded public user cross-signing key */
|
||||
export const BOB_USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw";
|
||||
|
||||
/** base64-encoded private user signing cross-signing key */
|
||||
export const BOB_USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "VXNlcnVzZXJ1c2VydXNlcnVzZXJ1c2VydXNlcnVzZXI";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const BOB_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
"master_keys": {
|
||||
"@bob:xyz": {
|
||||
"keys": {
|
||||
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA"
|
||||
},
|
||||
"user_id": "@bob:xyz",
|
||||
"usage": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
"@bob:xyz": {
|
||||
"keys": {
|
||||
"ed25519:DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A": "DaScI3WulBvDjf/d2vdyP5Cgjdypn1c/PSDX23MgN+A"
|
||||
},
|
||||
"user_id": "@bob:xyz",
|
||||
"usage": [
|
||||
"self_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "RxM8iJU6ZkyzQSVtNnXIJMPyEahVsN+fQQTBNKAs+kqySFyXBgchx+8czZaAhJCpXh9gD1nskT4yyFd2eyUXBw"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_signing_keys": {
|
||||
"@bob:xyz": {
|
||||
"keys": {
|
||||
"ed25519:lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw": "lXP89FP6zvFH9TSbU1S8uSdAsVawm1NmV6z+Rfr3lEw"
|
||||
},
|
||||
"user_id": "@bob:xyz",
|
||||
"usage": [
|
||||
"user_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:KKVOHOB2LsW7hFJwqyzXpA+vp7u5+gaMWUJvBS7mjuA": "jF8fvnPZulrPyh/4E8dNDVBP3iHHl9bRc+rRArVyGzoom+uVrokOck7BN2YmPyCRFZJJx7fgRA1Bveyu+mTVAg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "X9SK5JceUdvUr9gZkmdfS78qmKztoL60oORJ9JA5XD8",
|
||||
"session_key": "AQAAAADbfOdUj/ec5bK4xVEhhRw+jfd1FD4uA0MLy7NyVHugZxyXNZUx5YEof4H9YyjBretviZreMSXqflbgYKz257rkKu7MPeKFf7zmln2GxX0F/p++GOnvpY1FqOglhfRQi3tqiyOa7SL4f7TuERDTOpMqlWhIfTKQnqy0AyF2vpDi5V/UiuSXHlHb1K/YGZJnX0u/Kpis7aC+tKDkSfSQOVw/",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "ZG6lrfATe+958wN1xaGf3dKG/CThEfkmNdp1jcu4zok"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA",
|
||||
"session_key": "AQAAAACv0khqPrQ91MmWCgm0RTzfpn65AGCrRnAKLxGJdfSfECNZ8gyj34FZLwi+F+xC6ibFddcbLXW0mzR6PnTnHF3VHM4g/h+2rcxtlix8fySpIwFzaXViba7cOSy/b+dHTMZB40iA7F4y7AdTdHLv4N1XUj3puU/KVUIKf9/lEDLqyReD+39WdEY24mTIB5NcQQhtyguPzYPT5sSyeIUNd7Bw",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "HxUKnGfeUu0fF3cLyCFSDXYtVCQHy/+33q9RkzKfsiU"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
}
|
||||
];
|
||||
|
||||
/** An exported megolm session */
|
||||
export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM",
|
||||
"session_key": "AQAAAACvcoGk7mOY59fOqZaxFUiTCBRV1Ia94KBjAZx6kgdgBtkkvs50z8od8/Nc9ncK2UsEiXNvCTTp2dlN3du+Rx0/m7vet2ZOEEp2oYDjHMLLFmwd1gtlGuWYPdXA6Y1+9Yyph0/EDVfS+zd3XvbL0QgbyL43+yQnFNHKlxVJX1eiKTrGTHQtYEOZz6/i/bbk+sV7GhSZFT5IMT9hXsRxdf0D",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "dV0TIhhkToXpL+gZLo+zXDHJfw7MWYxpg80cynIQDv0"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const BOB_ONE_TIME_KEYS = {
|
||||
"@bob:xyz": {
|
||||
"bob_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "dlZc9VA/hP980Mxvu9qwi0qJx8VK7sADGOM48CE01YM7K/Mbty9lis/QjtQAWqDg371QyynVRjEzt9qj7eSFCg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,19 @@ import "../olm-loader";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
IJoinedRoom,
|
||||
IPusher,
|
||||
ISyncResponse,
|
||||
MatrixClient,
|
||||
MsgType,
|
||||
RelationType,
|
||||
} from "../../src";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
import { TEST_ROOM_ID } from "./test-data";
|
||||
|
||||
/**
|
||||
* Return a promise that is resolved when the client next emits a
|
||||
@@ -39,6 +49,61 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
|
||||
* @param roomMembers
|
||||
* @param roomId
|
||||
*
|
||||
* @returns the sync response
|
||||
*/
|
||||
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
|
||||
const roomResponse: IJoinedRoom = {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
"m.joined_member_count": roomMembers.length,
|
||||
"m.invited_member_count": roomMembers.length,
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
mkEventCustom({
|
||||
sender: roomMembers[0],
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
events: [],
|
||||
prev_batch: "",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
};
|
||||
|
||||
for (let i = 0; i < roomMembers.length; i++) {
|
||||
roomResponse.state.events.push(
|
||||
mkMembershipCustom({
|
||||
membership: "join",
|
||||
sender: roomMembers[i],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
next_batch: "1",
|
||||
rooms: {
|
||||
join: { [roomId]: roomResponse },
|
||||
invite: {},
|
||||
leave: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spy for an object and automatically spy its methods.
|
||||
* @param constr - The class constructor (used with 'new')
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RelationType } from "../../src/@types/event";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { mkMessage } from "./test-utils";
|
||||
|
||||
export const makeThreadEvent = ({
|
||||
@@ -34,7 +34,7 @@ export const makeThreadEvent = ({
|
||||
...props,
|
||||
relatesTo: {
|
||||
event_id: rootEventId,
|
||||
rel_type: "m.thread",
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
["m.in_reply_to"]: {
|
||||
event_id: replyToEventId,
|
||||
},
|
||||
|
||||
+123
-10
@@ -15,10 +15,17 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src";
|
||||
import { AutoDiscovery } from "../../src/autodiscovery";
|
||||
import { OidcError } from "../../src/oidc/error";
|
||||
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
|
||||
|
||||
// keep to reset the fetch function after using MockHttpBackend
|
||||
// @ts-ignore private property
|
||||
const realAutoDiscoveryFetch: typeof global.fetch = AutoDiscovery.fetchFn;
|
||||
|
||||
describe("AutoDiscovery", function () {
|
||||
const getHttpBackend = (): MockHttpBackend => {
|
||||
@@ -27,6 +34,10 @@ describe("AutoDiscovery", function () {
|
||||
return httpBackend;
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||
});
|
||||
|
||||
it("should throw an error when no domain is specified", function () {
|
||||
getHttpBackend();
|
||||
return Promise.all([
|
||||
@@ -340,7 +351,7 @@ describe("AutoDiscovery", function () {
|
||||
function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
not_matrix_versions: ["r0.0.1"],
|
||||
not_matrix_versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -377,7 +388,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -417,7 +428,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -458,7 +469,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -504,7 +515,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -549,7 +560,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -595,7 +606,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -642,7 +653,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -686,7 +697,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -736,7 +747,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -855,4 +866,106 @@ describe("AutoDiscovery", function () {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should FAIL_ERROR for unsupported Matrix version", () => {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
base_url: "https://example.org",
|
||||
},
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.6.0"],
|
||||
});
|
||||
return Promise.all([
|
||||
httpBackend.flushAllExpected(),
|
||||
AutoDiscovery.findClientConfig("example.org").then((conf) => {
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: AutoDiscoveryAction.FAIL_ERROR,
|
||||
error: AutoDiscovery.ERROR_HOMESERVER_TOO_OLD,
|
||||
base_url: "https://example.org",
|
||||
},
|
||||
"m.identity_server": {
|
||||
state: "PROMPT",
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("m.authentication", () => {
|
||||
const homeserverName = "example.org";
|
||||
const homeserverUrl = "https://chat.example.org/";
|
||||
const issuer = "https://auth.org/";
|
||||
|
||||
beforeAll(() => {
|
||||
// make these tests independent from fetch mocking above
|
||||
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetBehavior();
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
|
||||
|
||||
fetchMock.get("https://example.org/.well-known/matrix/client", {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
base_url: "https://chat.example.org/",
|
||||
},
|
||||
"m.authentication": {
|
||||
issuer,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid authentication configuration", async () => {
|
||||
const config = makeDelegatedAuthConfig(issuer);
|
||||
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||
|
||||
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||
state: AutoDiscovery.SUCCESS,
|
||||
...config,
|
||||
signingKeys: [],
|
||||
account: undefined,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set state to error for invalid authentication configuration", async () => {
|
||||
const config = makeDelegatedAuthConfig(issuer);
|
||||
// authorization_code is required
|
||||
config.metadata.grant_types_supported = ["openid"];
|
||||
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||
|
||||
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||
state: AutoDiscovery.FAIL_ERROR,
|
||||
error: OidcError.OpSupport,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ContentRepo", function () {
|
||||
it("should return a download URL if no width/height/resize are specified", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
|
||||
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
|
||||
baseUrl + "/_matrix/media/v3/download/server.name/resourceid",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,21 +44,21 @@ describe("ContentRepo", function () {
|
||||
it("should return a thumbnail URL if a width/height/resize is specified", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
|
||||
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32&height=64&method=crop",
|
||||
baseUrl + "/_matrix/media/v3/thumbnail/server.name/resourceid" + "?width=32&height=64&method=crop",
|
||||
);
|
||||
});
|
||||
|
||||
it("should put fragments from mxc:// URIs after any query parameters", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid#automade";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
|
||||
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + "?width=32#automade",
|
||||
baseUrl + "/_matrix/media/v3/thumbnail/server.name/resourceid" + "?width=32#automade",
|
||||
);
|
||||
});
|
||||
|
||||
it("should put fragments from mxc:// URIs at the end of the HTTP URI", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid#automade";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
|
||||
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
|
||||
baseUrl + "/_matrix/media/v3/download/server.name/resourceid#automade",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("DeviceList", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have an outdated devicelist on an invalidation while an " + "update is in progress", function () {
|
||||
it("should have an outdated devicelist on an invalidation while an update is in progress", async function () {
|
||||
const dl = createTestDeviceList();
|
||||
|
||||
dl.startTrackingDeviceList("@test1:sw1v.org");
|
||||
@@ -148,11 +148,8 @@ describe("DeviceList", function () {
|
||||
dl.invalidateUserDeviceList("@test1:sw1v.org");
|
||||
dl.refreshOutdatedDeviceLists();
|
||||
|
||||
// TODO: Fix this test so we actually await the call and assertions and remove
|
||||
// the eslint disable, https://github.com/matrix-org/matrix-js-sdk/issues/2977
|
||||
//
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
dl.saveIfDirty()
|
||||
await dl
|
||||
.saveIfDirty()
|
||||
.then(() => {
|
||||
// the first request completes
|
||||
queryDefer1.resolve({
|
||||
@@ -163,12 +160,13 @@ describe("DeviceList", function () {
|
||||
});
|
||||
return prom1;
|
||||
})
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
// uh-oh; user restarts before second request completes. The new instance
|
||||
// should know we never got a complete device list.
|
||||
logger.log("Creating new devicelist to simulate app reload");
|
||||
downloadSpy.mockReset();
|
||||
const dl2 = createTestDeviceList();
|
||||
await dl2.load();
|
||||
const queryDefer3 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValue(queryDefer3.promise);
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@ exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`]
|
||||
"params": {
|
||||
"access_token": "token",
|
||||
},
|
||||
"path": "/_matrix/media/r0/upload",
|
||||
"path": "/_matrix/media/v3/upload",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { QueryDict } from "../../../src/utils";
|
||||
import { defer, QueryDict } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -274,11 +277,13 @@ describe("FetchHttpApi", () => {
|
||||
];
|
||||
const runTests = (fetchBaseUrl: string) => {
|
||||
it.each<TestCase>(testCases)(
|
||||
"creates url with params %s",
|
||||
({ path, queryParams, prefix, baseUrl }, result) => {
|
||||
"creates url with params %s => %s",
|
||||
({ path, queryParams, prefix, baseUrl }, expected) => {
|
||||
const api = makeApi(fetchBaseUrl);
|
||||
|
||||
expect(api.getUrl(path, queryParams, prefix, baseUrl)).toEqual(new URL(result));
|
||||
const result = api.getUrl(path, queryParams, prefix, baseUrl);
|
||||
// we only check the stringified URL, to avoid having the test depend on the internals of URL.
|
||||
expect(result.toString()).toEqual(expected);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -290,4 +295,30 @@ describe("FetchHttpApi", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(logger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mocked(logger.debug).mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
|
||||
]
|
||||
`);
|
||||
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("MatrixHttpApi", () => {
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(xhr.open).toHaveBeenCalledWith(
|
||||
Method.Post,
|
||||
baseUrl.toLowerCase() + "/_matrix/media/r0/upload?access_token=token",
|
||||
baseUrl.toLowerCase() + "/_matrix/media/v3/upload?access_token=token",
|
||||
);
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalledWith("Authorization");
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe("MatrixHttpApi", () => {
|
||||
accessToken: "token",
|
||||
});
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload");
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith("Authorization", "Bearer token");
|
||||
});
|
||||
|
||||
@@ -105,14 +105,14 @@ describe("MatrixHttpApi", () => {
|
||||
upload = api.uploadContent({} as File, { name: "name" });
|
||||
expect(xhr.open).toHaveBeenCalledWith(
|
||||
Method.Post,
|
||||
baseUrl.toLowerCase() + "/_matrix/media/r0/upload?filename=name",
|
||||
baseUrl.toLowerCase() + "/_matrix/media/v3/upload?filename=name",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow not sending the filename", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
upload = api.uploadContent({} as File, { name: "name", includeFilename: false });
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/r0/upload");
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
|
||||
});
|
||||
|
||||
it("should abort xhr when the upload is aborted", () => {
|
||||
|
||||
@@ -375,7 +375,7 @@ describe("InteractiveAuth", () => {
|
||||
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("No appropriate authentication flow found"));
|
||||
});
|
||||
|
||||
it("should handle unexpected error types without data propery set", async () => {
|
||||
it("should handle unexpected error types without data property set", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
@@ -559,4 +559,40 @@ describe("InteractiveAuth", () => {
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should fire stateUpdated callback if with error when encountered", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Password] }],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
doRequest.mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN", error: "This is an error" }));
|
||||
|
||||
await Promise.allSettled([ia.attemptAuth()]);
|
||||
expect(stateUpdated).toHaveBeenCalledWith("m.login.password", {
|
||||
errcode: "M_UNKNOWN",
|
||||
error: "This is an error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("refreshToken", () => {
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v3", async () => {
|
||||
@@ -132,7 +132,7 @@ describe("refreshToken", () => {
|
||||
throw new Error("/v1/refresh unexpectedly called");
|
||||
});
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v1", async () => {
|
||||
@@ -144,6 +144,6 @@ describe("refreshToken", () => {
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), 429);
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("MatrixClient", function () {
|
||||
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
|
||||
return Promise.resolve({
|
||||
unstable_features: unstableFeatures,
|
||||
versions: ["r0.6.0", "r0.6.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
}
|
||||
const next = httpLookups.shift();
|
||||
|
||||
@@ -703,7 +703,11 @@ async function createThread(client: MatrixClient, user: string, roomId: string):
|
||||
root.setThreadId(root.getId());
|
||||
await room.addLiveEvents([root]);
|
||||
|
||||
return room.createThread(root.getId()!, root, [], false);
|
||||
// Create the thread and wait for it to be initialised
|
||||
const thread = room.createThread(root.getId()!, root, [], false);
|
||||
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -25,8 +29,10 @@ import {
|
||||
completeAuthorizationCodeGrant,
|
||||
generateAuthorizationParams,
|
||||
generateAuthorizationUrl,
|
||||
generateOidcAuthorizationUrl,
|
||||
} from "../../../src/oidc/authorize";
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||
|
||||
jest.mock("jwt-decode");
|
||||
|
||||
@@ -34,20 +40,20 @@ jest.mock("jwt-decode");
|
||||
const realSubtleCrypto = crypto.subtleCrypto;
|
||||
|
||||
describe("oidc authorization", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const authorizationEndpoint = "https://auth.com/authorization";
|
||||
const tokenEndpoint = "https://auth.com/token";
|
||||
const delegatedAuthConfig = {
|
||||
issuer,
|
||||
registrationEndpoint: issuer + "registration",
|
||||
authorizationEndpoint: issuer + "auth",
|
||||
tokenEndpoint,
|
||||
};
|
||||
const delegatedAuthConfig = makeDelegatedAuthConfig();
|
||||
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
|
||||
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
|
||||
const clientId = "xyz789";
|
||||
const baseUrl = "https://test.com";
|
||||
|
||||
// 14.03.2022 16:15
|
||||
const now = 1647270879403;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(logger, "warn");
|
||||
jest.setSystemTime(now);
|
||||
|
||||
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,25 +103,42 @@ describe("oidc authorization", () => {
|
||||
"A secure context is required to generate code challenge. Using plain text code challenge",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOidcAuthorizationUrl()", () => {
|
||||
it("should generate url with correct parameters", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const metadata = delegatedAuthConfig.metadata;
|
||||
|
||||
it("uses a s256 code challenge when crypto is available", async () => {
|
||||
jest.spyOn(crypto.subtleCrypto, "digest");
|
||||
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
const authUrl = new URL(
|
||||
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
|
||||
const codeChallenge = authUrl.searchParams.get("code_challenge");
|
||||
expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object));
|
||||
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
|
||||
expect(authUrl.searchParams.get("response_type")).toEqual("code");
|
||||
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
|
||||
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
|
||||
// scope minus the 10char random device id at the end
|
||||
expect(authUrl.searchParams.get("scope")!.slice(0, -10)).toEqual(
|
||||
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:",
|
||||
);
|
||||
expect(authUrl.searchParams.get("state")).toBeTruthy();
|
||||
expect(authUrl.searchParams.get("nonce")).toEqual(nonce);
|
||||
|
||||
// didn't use plain text code challenge
|
||||
expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge);
|
||||
expect(codeChallenge).toBeTruthy();
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
const codeVerifier = "abc123";
|
||||
const homeserverUrl = "https://server.org/";
|
||||
const identityServerUrl = "https://id.org/";
|
||||
const nonce = "test-nonce";
|
||||
const redirectUri = baseUrl;
|
||||
const code = "auth_code_xyz";
|
||||
@@ -124,9 +147,11 @@ describe("oidc authorization", () => {
|
||||
access_token: "test_access_token",
|
||||
refresh_token: "test_refresh_token",
|
||||
id_token: "valid.id.token",
|
||||
expires_in: 12345,
|
||||
expires_in: 300,
|
||||
};
|
||||
|
||||
const metadata = mockOpenIdConfiguration();
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
@@ -135,131 +160,236 @@ describe("oidc authorization", () => {
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
iss: delegatedAuthConfig.issuer,
|
||||
iss: metadata.issuer,
|
||||
sub: "123",
|
||||
};
|
||||
|
||||
const mockSessionStorage = (state: Record<string, unknown>): void => {
|
||||
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation((key: unknown) => {
|
||||
return state[key as string] ?? null;
|
||||
});
|
||||
jest.spyOn(sessionStorage.__proto__, "setItem").mockImplementation(
|
||||
// @ts-ignore mock type
|
||||
(key: string, value: unknown) => (state[key] = value),
|
||||
);
|
||||
jest.spyOn(sessionStorage.__proto__, "removeItem").mockImplementation((key: unknown) => {
|
||||
const { [key as string]: value, ...newState } = state;
|
||||
state = newState;
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const getValueFromStorage = <T = string>(state: string, key: string): T => {
|
||||
const storedState = window.sessionStorage.getItem(`mx_oidc_${state}`)!;
|
||||
return JSON.parse(storedState)[key] as unknown as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* These tests kind of integration test oidc auth, by using `generateOidcAuthorizationUrl` and mocked storage
|
||||
* to mock the use case of initiating oidc auth, putting state in storage, redirecting to OP,
|
||||
* then returning and using state to verfiy.
|
||||
* Returns random state string used to access storage
|
||||
* @param params
|
||||
*/
|
||||
const setupState = async (params = {}): Promise<string> => {
|
||||
const url = await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
redirectUri,
|
||||
clientId,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
nonce,
|
||||
...params,
|
||||
});
|
||||
|
||||
const state = new URL(url).searchParams.get("state")!;
|
||||
|
||||
// add the scope with correct deviceId to the mocked bearer token response
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
fetchMock.post(metadata.token_endpoint, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...validBearerTokenResponse,
|
||||
scope,
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
fetchMock.resetBehavior();
|
||||
|
||||
fetchMock.post(tokenEndpoint, {
|
||||
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
|
||||
fetchMock.get(`${metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
body: JSON.stringify(validBearerTokenResponse),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
mockSessionStorage({});
|
||||
|
||||
mocked(jwtDecode).mockReturnValue(validDecodedIdToken);
|
||||
});
|
||||
|
||||
it("should make correct request to the token endpoint", async () => {
|
||||
await completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
});
|
||||
const state = await setupState();
|
||||
const codeVerifier = getValueFromStorage(state, "code_verifier");
|
||||
await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(tokenEndpoint, {
|
||||
method: Method.Post,
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
metadata.token_endpoint,
|
||||
expect.objectContaining({
|
||||
method: Method.Post,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// check body is correctly formed
|
||||
const queryParams = fetchMock.mock.calls.find(([endpoint]) => endpoint === metadata.token_endpoint)![1]!
|
||||
.body as URLSearchParams;
|
||||
expect(queryParams.get("grant_type")).toEqual("authorization_code");
|
||||
expect(queryParams.get("client_id")).toEqual(clientId);
|
||||
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
|
||||
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
|
||||
expect(queryParams.get("code")).toEqual(code);
|
||||
});
|
||||
|
||||
it("should return with valid bearer token", async () => {
|
||||
const result = await completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
});
|
||||
const state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(result).toEqual(validBearerTokenResponse);
|
||||
expect(result).toEqual({
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
oidcClientSettings: {
|
||||
clientId,
|
||||
issuer: metadata.issuer,
|
||||
},
|
||||
tokenResponse: {
|
||||
access_token: validBearerTokenResponse.access_token,
|
||||
id_token: validBearerTokenResponse.id_token,
|
||||
refresh_token: validBearerTokenResponse.refresh_token,
|
||||
token_type: validBearerTokenResponse.token_type,
|
||||
// this value is slightly unstable because it uses the clock
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return with valid bearer token where token_type is lowercase", async () => {
|
||||
const state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const tokenResponse = {
|
||||
...validBearerTokenResponse,
|
||||
scope,
|
||||
token_type: "bearer",
|
||||
};
|
||||
fetchMock.post(
|
||||
tokenEndpoint,
|
||||
{
|
||||
status: 200,
|
||||
body: JSON.stringify(tokenResponse),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...tokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const result = await completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(result).toEqual({
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
oidcClientSettings: {
|
||||
clientId,
|
||||
issuer: metadata.issuer,
|
||||
},
|
||||
// results in token that uses 'Bearer' token type
|
||||
tokenResponse: {
|
||||
access_token: validBearerTokenResponse.access_token,
|
||||
id_token: validBearerTokenResponse.id_token,
|
||||
refresh_token: validBearerTokenResponse.refresh_token,
|
||||
token_type: "Bearer",
|
||||
// this value is slightly unstable because it uses the clock
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
|
||||
// results in token that uses 'Bearer' token type
|
||||
expect(result).toEqual(validBearerTokenResponse);
|
||||
expect(result.token_type).toEqual("Bearer");
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
});
|
||||
|
||||
it("should throw with code exchange failed error when request fails", async () => {
|
||||
it("should throw when state is not found in storage", async () => {
|
||||
// don't setup sessionStorage with expected state
|
||||
const state = "abc123";
|
||||
fetchMock.post(
|
||||
tokenEndpoint,
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() =>
|
||||
completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
}),
|
||||
).rejects.toThrow(new Error(OidcError.CodeExchangeFailed));
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.MissingOrInvalidStoredState),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw with code exchange failed error when request fails", async () => {
|
||||
const state = await setupState();
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.CodeExchangeFailed),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw invalid token error when token is invalid", async () => {
|
||||
const state = await setupState();
|
||||
const invalidBearerTokenResponse = {
|
||||
...validBearerTokenResponse,
|
||||
access_token: null,
|
||||
};
|
||||
fetchMock.post(
|
||||
tokenEndpoint,
|
||||
{ status: 200, body: JSON.stringify(invalidBearerTokenResponse) },
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...invalidBearerTokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() =>
|
||||
completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
}),
|
||||
).rejects.toThrow(new Error(OidcError.InvalidBearerTokenResponse));
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidBearerTokenResponse),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw invalid id token error when id_token is invalid", async () => {
|
||||
const state = await setupState();
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
// invalid audience
|
||||
aud: "something-else",
|
||||
});
|
||||
await expect(() =>
|
||||
completeAuthorizationCodeGrant(code, {
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
}),
|
||||
).rejects.toThrow(new Error(OidcError.InvalidIdToken));
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidIdToken),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("registerOidcClient()", () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 500,
|
||||
});
|
||||
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationFailed,
|
||||
);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe("registerOidcClient()", () => {
|
||||
// no clientId in response
|
||||
body: "{}",
|
||||
});
|
||||
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
},
|
||||
};
|
||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
|
||||
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||
@@ -45,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: { url: "test.com" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||
@@ -56,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: { url: "test" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is false", () => {
|
||||
@@ -67,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: false,
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses stable m.authentication", () => {
|
||||
@@ -78,7 +84,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: "account.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
});
|
||||
@@ -91,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: "test.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
@@ -104,24 +110,10 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
somethingElse: "test",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses unstable prefix for m.authentication", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.unstable!]: {
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOIDCIssuerWellKnown", () => {
|
||||
@@ -129,6 +121,7 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
authorization_endpoint: "https://test.org/authorize",
|
||||
token_endpoint: "https://authorize.org/token",
|
||||
registration_endpoint: "https://authorize.org/regsiter",
|
||||
revocation_endpoint: "https://authorize.org/regsiter",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
@@ -155,10 +148,8 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
response_types_supported: [],
|
||||
});
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"OIDC issuer configuration: response_types_supported is invalid. code is required.",
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith("Missing or invalid property: authorization_endpoint");
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid property: response_types_supported. code is required.");
|
||||
});
|
||||
|
||||
it("should return validated issuer config", () => {
|
||||
|
||||
@@ -657,7 +657,7 @@ describe("NotificationService", function () {
|
||||
content: {
|
||||
"body": "",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc3952.mentions": {},
|
||||
"m.mentions": {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
@@ -219,7 +219,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
@@ -243,7 +243,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
+130
-32
@@ -16,9 +16,9 @@ limitations under the License.
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { EventType } from "../../src/matrix";
|
||||
import { EventType, MatrixEvent, Room } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -42,42 +42,46 @@ 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",
|
||||
},
|
||||
});
|
||||
|
||||
describe("Read receipt", () => {
|
||||
let threadEvent: MatrixEvent;
|
||||
let roomEvent: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
client = new MatrixClient({
|
||||
userId: "@user:server",
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
});
|
||||
client.isGuest = () => false;
|
||||
client.supportsThreads = () => true;
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
roomEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: {
|
||||
body: "Hello from a room",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendReceipt", () => {
|
||||
@@ -143,13 +147,69 @@ describe("Read receipt", () => {
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("should send a main timeline read receipt for a reaction to a thread root", async () => {
|
||||
roomEvent.event.event_id = THREAD_ID;
|
||||
const reaction = utils.mkReaction(roomEvent, client, client.getSafeUserId(), ROOM_ID);
|
||||
const thread = new Room(ROOM_ID, client, client.getSafeUserId()).createThread(
|
||||
THREAD_ID,
|
||||
roomEvent,
|
||||
[threadEvent],
|
||||
false,
|
||||
);
|
||||
threadEvent.setThread(thread);
|
||||
reaction.setThread(thread);
|
||||
|
||||
httpBackend
|
||||
.when(
|
||||
"POST",
|
||||
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: reaction.getId()!,
|
||||
}),
|
||||
)
|
||||
.check((request) => {
|
||||
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
client.sendReceipt(reaction, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("should always send unthreaded receipts if threads support is disabled", async () => {
|
||||
client.supportsThreads = () => false;
|
||||
|
||||
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(undefined);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
client.sendReceipt(roomEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesizeReceipt", () => {
|
||||
it.each([
|
||||
{ event: roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ event: threadEvent, destinationId: threadEvent.threadRootId! },
|
||||
])("adds the receipt to $destinationId", ({ event, destinationId }) => {
|
||||
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
|
||||
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
|
||||
const event = getEvent();
|
||||
const userId = "@bob:example.org";
|
||||
const receiptType = ReceiptType.Read;
|
||||
|
||||
@@ -160,4 +220,42 @@ describe("Read receipt", () => {
|
||||
expect(content.thread_id).toEqual(destinationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addReceiptToStructure", () => {
|
||||
it("should not allow an older unthreaded receipt to clobber a `main` threaded one", () => {
|
||||
const userId = client.getSafeUserId();
|
||||
const room = new Room(ROOM_ID, client, userId);
|
||||
|
||||
const unthreadedReceipt: WrappedReceipt = {
|
||||
eventId: "$olderEvent",
|
||||
data: {
|
||||
ts: 1234567880,
|
||||
},
|
||||
};
|
||||
const mainTimelineReceipt: WrappedReceipt = {
|
||||
eventId: "$newerEvent",
|
||||
data: {
|
||||
ts: 1234567890,
|
||||
},
|
||||
};
|
||||
|
||||
room.addReceiptToStructure(
|
||||
mainTimelineReceipt.eventId,
|
||||
ReceiptType.ReadPrivate,
|
||||
userId,
|
||||
mainTimelineReceipt.data,
|
||||
false,
|
||||
);
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
|
||||
room.addReceiptToStructure(
|
||||
unthreadedReceipt.eventId,
|
||||
ReceiptType.ReadPrivate,
|
||||
userId,
|
||||
unthreadedReceipt.data,
|
||||
false,
|
||||
);
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("ECDHv2", function () {
|
||||
|
||||
expect(aliceChecksum).toEqual(bobChecksum);
|
||||
|
||||
expect(alice.connect()).rejects.toThrow();
|
||||
await expect(alice.connect()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
@@ -120,9 +120,9 @@ describe("ECDHv2", function () {
|
||||
|
||||
alice.close();
|
||||
|
||||
expect(alice.connect()).rejects.toThrow();
|
||||
expect(alice.send({})).rejects.toThrow();
|
||||
expect(alice.receive()).rejects.toThrow();
|
||||
await expect(alice.connect()).rejects.toThrow();
|
||||
await expect(alice.send({})).rejects.toThrow();
|
||||
await expect(alice.receive()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
@@ -146,10 +146,10 @@ describe("ECDHv2", function () {
|
||||
|
||||
// send a message without encryption
|
||||
await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" });
|
||||
expect(bob.receive()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
await expect(bob.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("ciphertext before set up", async function () {
|
||||
@@ -164,9 +164,8 @@ describe("ECDHv2", function () {
|
||||
|
||||
await bobTransport.send({ iv: "dummy", ciphertext: "dummy" });
|
||||
|
||||
expect(alice.receive()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await expect(alice.receive()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined);
|
||||
await httpBackend.flush("");
|
||||
expect(cancelPromise).resolves.toBeUndefined();
|
||||
await expect(cancelPromise).resolves.toBeUndefined();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
|
||||
@@ -603,7 +603,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
it("device not online within timeout", async function () {
|
||||
const { aliceRz } = await completeLogin({});
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("device appears online within timeout", async function () {
|
||||
@@ -627,7 +627,7 @@ describe("Rendezvous", function () {
|
||||
getFingerprint: () => "bbbb",
|
||||
};
|
||||
}, 1500);
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("mismatched device key", async function () {
|
||||
@@ -636,6 +636,6 @@ describe("Rendezvous", function () {
|
||||
getFingerprint: () => "XXXX",
|
||||
},
|
||||
});
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,10 +95,10 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
}
|
||||
}
|
||||
it("should throw an error when no server available", function () {
|
||||
it("should throw an error when no server available", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
|
||||
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn });
|
||||
expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
|
||||
await expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
|
||||
});
|
||||
|
||||
it("POST to fallback server", async function () {
|
||||
@@ -130,7 +130,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fetchFn,
|
||||
});
|
||||
const prom = simpleHttpTransport.send({});
|
||||
expect(prom).rejects.toThrow();
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
response: {
|
||||
@@ -138,7 +137,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("");
|
||||
await Promise.all([expect(prom).rejects.toThrow(), httpBackend.flush("")]);
|
||||
});
|
||||
|
||||
it("POST with absolute path response", async function () {
|
||||
@@ -364,7 +363,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
fetchFn,
|
||||
});
|
||||
expect(simpleHttpTransport.details()).rejects.toThrow();
|
||||
await expect(simpleHttpTransport.details()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("send after cancelled", async function () {
|
||||
@@ -375,7 +374,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fetchFn,
|
||||
});
|
||||
await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined);
|
||||
expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
|
||||
await expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("receive before ready", async function () {
|
||||
@@ -385,7 +384,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
fetchFn,
|
||||
});
|
||||
expect(simpleHttpTransport.receive()).rejects.toThrow();
|
||||
await expect(simpleHttpTransport.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("404 failure callback", async function () {
|
||||
@@ -398,7 +397,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
onFailure,
|
||||
});
|
||||
|
||||
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined();
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
response: {
|
||||
@@ -406,7 +404,10 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("", 1);
|
||||
await Promise.all([
|
||||
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined(),
|
||||
httpBackend.flush("", 1),
|
||||
]);
|
||||
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown);
|
||||
});
|
||||
|
||||
@@ -438,7 +439,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
}
|
||||
{
|
||||
// GET with 404 to simulate expiry
|
||||
expect(simpleHttpTransport.receive()).resolves.toBeUndefined();
|
||||
httpBackend.when("GET", "https://fallbackserver/rz/123").response = {
|
||||
body: { foo: "baa" },
|
||||
response: {
|
||||
@@ -446,7 +446,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("");
|
||||
await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]);
|
||||
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { EventType, MatrixClient, Room } from "../../src";
|
||||
import { RoomHierarchy } from "../../src/room-hierarchy";
|
||||
|
||||
describe("RoomHierarchy", () => {
|
||||
const roomId = "!room:server";
|
||||
const client = new MatrixClient({ baseUrl: "https://server", userId: "@user:server" });
|
||||
|
||||
it("should load data from /hierarchy API", async () => {
|
||||
const spy = fetchMock.getOnce(
|
||||
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
|
||||
{
|
||||
rooms: [],
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const room = new Room(roomId, client, client.getSafeUserId());
|
||||
const hierarchy = new RoomHierarchy(room);
|
||||
const res = await hierarchy.load();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(res).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("itSuggested", () => {
|
||||
it("should return true if a room is suggested", async () => {
|
||||
const spy = fetchMock.getOnce(
|
||||
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
|
||||
{
|
||||
rooms: [
|
||||
{
|
||||
children_state: [
|
||||
{
|
||||
origin_server_ts: 111,
|
||||
content: {
|
||||
suggested: true,
|
||||
via: ["matrix.org"],
|
||||
},
|
||||
type: EventType.SpaceChild,
|
||||
state_key: "!child_room:server",
|
||||
},
|
||||
],
|
||||
room_id: roomId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const room = new Room(roomId, client, client.getSafeUserId());
|
||||
const hierarchy = new RoomHierarchy(room);
|
||||
await hierarchy.load();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(hierarchy.isSuggested(hierarchy.root.roomId, "!child_room:server")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
-34
@@ -2556,7 +2556,7 @@ describe("Room", function () {
|
||||
next_batch: "start_token",
|
||||
});
|
||||
|
||||
let prom = emitPromise(room, ThreadEvent.New);
|
||||
const prom = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([randomMessage, threadRoot, threadResponse]);
|
||||
const thread: Thread = await prom;
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
@@ -2583,9 +2583,11 @@ describe("Room", function () {
|
||||
},
|
||||
});
|
||||
|
||||
prom = emitPromise(room, ThreadEvent.Update);
|
||||
await room.addLiveEvents([threadResponseEdit]);
|
||||
await prom;
|
||||
// XXX: If we add the relation to the thread response before the thread finishes fetching via /relations
|
||||
// then the test will fail
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await Promise.all([emitPromise(room, ThreadEvent.Update), room.addLiveEvents([threadResponseEdit])]);
|
||||
expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
|
||||
});
|
||||
|
||||
@@ -2765,7 +2767,7 @@ describe("Room", function () {
|
||||
"m.relations": {
|
||||
"m.thread": {
|
||||
latest_event: threadResponse2.event,
|
||||
count: 2,
|
||||
count: 1,
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
@@ -2787,10 +2789,10 @@ describe("Room", function () {
|
||||
let prom = emitPromise(room, ThreadEvent.New);
|
||||
await room.addLiveEvents([threadRoot, threadResponse1]);
|
||||
const thread: Thread = await prom;
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
|
||||
expect(thread.initialEventsFetched).toBeTruthy();
|
||||
await room.addLiveEvents([threadResponse2]);
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
expect(thread).toHaveLength(2);
|
||||
expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId());
|
||||
|
||||
@@ -2809,11 +2811,10 @@ describe("Room", function () {
|
||||
},
|
||||
});
|
||||
|
||||
prom = emitPromise(room, ThreadEvent.Update);
|
||||
const threadResponse2Redaction = mkRedaction(threadResponse2);
|
||||
await room.addLiveEvents([threadResponse2Redaction]);
|
||||
await prom;
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
const threadResponse2Redaction = mkRedaction(threadResponse2);
|
||||
await emitPromise(room, ThreadEvent.Update);
|
||||
await room.addLiveEvents([threadResponse2Redaction]);
|
||||
expect(thread).toHaveLength(1);
|
||||
expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId());
|
||||
|
||||
@@ -2849,7 +2850,7 @@ describe("Room", function () {
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
const room = new Room(roomId, client, userA);
|
||||
|
||||
it("thread root and its relations&redactions should be in both", () => {
|
||||
it("thread root and its relations&redactions should be in main timeline", () => {
|
||||
const randomMessage = mkMessage();
|
||||
const threadRoot = mkMessage();
|
||||
const threadResponse1 = mkThreadResponse(threadRoot);
|
||||
@@ -2867,6 +2868,9 @@ describe("Room", function () {
|
||||
threadReaction2Redaction,
|
||||
];
|
||||
|
||||
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
|
||||
events.slice(1).forEach((ev) => ev.setThread(thread));
|
||||
|
||||
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
|
||||
@@ -2878,14 +2882,11 @@ describe("Room", function () {
|
||||
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
|
||||
|
||||
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId());
|
||||
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId());
|
||||
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId());
|
||||
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
});
|
||||
|
||||
it("thread response and its relations&redactions should be only in thread timeline", () => {
|
||||
@@ -2909,25 +2910,39 @@ describe("Room", function () {
|
||||
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId());
|
||||
});
|
||||
|
||||
it("reply to thread response and its relations&redactions should be only in main timeline", () => {
|
||||
it("reply to thread response and its relations&redactions should be only in thread timeline", () => {
|
||||
const threadRoot = mkMessage();
|
||||
const threadResponse1 = mkThreadResponse(threadRoot);
|
||||
const reply1 = mkReply(threadResponse1);
|
||||
const reaction1 = utils.mkReaction(reply1, room.client, userA, roomId);
|
||||
const reaction2 = utils.mkReaction(reply1, room.client, userA, roomId);
|
||||
const reaction2Redaction = mkRedaction(reply1);
|
||||
const threadResp1 = mkThreadResponse(threadRoot);
|
||||
const threadResp1Reply1 = mkReply(threadResp1);
|
||||
const threadResp1Reply1Reaction1 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
|
||||
const threadResp1Reply1Reaction2 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
|
||||
const thResp1Rep1React2Redaction = mkRedaction(threadResp1Reply1);
|
||||
|
||||
const roots = new Set([threadRoot.getId()!]);
|
||||
const events = [threadRoot, threadResponse1, reply1, reaction1, reaction2, reaction2Redaction];
|
||||
const events = [
|
||||
threadRoot,
|
||||
threadResp1,
|
||||
threadResp1Reply1,
|
||||
threadResp1Reply1Reaction1,
|
||||
threadResp1Reply1Reaction2,
|
||||
thResp1Rep1React2Redaction,
|
||||
];
|
||||
|
||||
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(reaction1, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reaction1, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(reaction2, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reaction2, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(reaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reaction2Redaction, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
|
||||
events.forEach((ev) => ev.setThread(thread));
|
||||
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInRoom).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).threadId).toBe(thread.id);
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInRoom).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).threadId).toBe(thread.id);
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInRoom).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).threadId).toBe(thread.id);
|
||||
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInRoom).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).threadId).toBe(thread.id);
|
||||
});
|
||||
|
||||
it("reply to reply to thread root should only be in the main timeline", () => {
|
||||
@@ -2939,12 +2954,40 @@ describe("Room", function () {
|
||||
const roots = new Set([threadRoot.getId()!]);
|
||||
const events = [threadRoot, threadResponse1, reply1, reply2];
|
||||
|
||||
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
|
||||
threadResponse1.setThread(thread);
|
||||
|
||||
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
});
|
||||
|
||||
it("edit to thread root should live in main timeline only", () => {
|
||||
const threadRoot = mkMessage();
|
||||
const threadResponse1 = mkThreadResponse(threadRoot);
|
||||
const threadRootEdit = mkEdit(threadRoot);
|
||||
threadRoot.makeReplaced(threadRootEdit);
|
||||
|
||||
const thread = room.createThread(threadRoot.getId()!, threadRoot, [threadResponse1], false);
|
||||
threadResponse1.setThread(thread);
|
||||
threadRootEdit.setThread(thread);
|
||||
|
||||
const roots = new Set([threadRoot.getId()!]);
|
||||
const events = [threadRoot, threadResponse1, threadRootEdit];
|
||||
|
||||
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId());
|
||||
|
||||
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy();
|
||||
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
|
||||
|
||||
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInThread).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should aggregate relations in thread event timeline set", async () => {
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
const threadRoot = mkMessage();
|
||||
@@ -2976,6 +3019,14 @@ describe("Room", function () {
|
||||
expect(responseRelations![0][1].size).toEqual(1);
|
||||
expect(responseRelations![0][1].has(threadReaction)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a non-thread reply to an unknown parent event should live in the main timeline only", async () => {
|
||||
const message = mkMessage(); // we do not add this message to any timelines
|
||||
const reply = mkReply(message);
|
||||
|
||||
expect(room.eventShouldLiveIn(reply).shouldLiveInRoom).toBeTruthy();
|
||||
expect(room.eventShouldLiveIn(reply).shouldLiveInThread).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventReadUpTo()", () => {
|
||||
@@ -3062,10 +3113,10 @@ describe("Room", function () {
|
||||
it("should give precedence to m.read.private", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1" } as WrappedReceipt;
|
||||
return { eventId: "eventId1", data: { ts: 123 } };
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2" } as WrappedReceipt;
|
||||
return { eventId: "eventId2", data: { ts: 123 } };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -15,10 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
@@ -31,6 +32,9 @@ describe("CrossSigningIdentity", () => {
|
||||
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
|
||||
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
/** A mock ServerSideSecretStorage which crossSigning is connected to */
|
||||
let secretStorage: Mocked<ServerSideSecretStorage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
@@ -44,7 +48,11 @@ describe("CrossSigningIdentity", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
|
||||
secretStorage = {
|
||||
get: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage, jest.fn());
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
|
||||
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { Mocked } from "jest-mock";
|
||||
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { KeysClaimRequest, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
import { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
KeysBackupRequest,
|
||||
KeysClaimRequest,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SignatureUploadRequest,
|
||||
SigningKeysUploadRequest,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
|
||||
@@ -161,7 +161,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
.when("PUT", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(
|
||||
"https://example.com/_matrix/client/v3/room/test%2Froom/send/test%2Ftype/test%2Ftxnid",
|
||||
"https://example.com/_matrix/client/v3/rooms/test%2Froom/send/test%2Ftype/test%2Ftxnid",
|
||||
);
|
||||
expect(req.rawData).toEqual(testBody);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
|
||||
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
@@ -23,6 +23,8 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import {
|
||||
CryptoEvent,
|
||||
Device,
|
||||
DeviceVerification,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IHttpOpts,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { CryptoCallbacks, ImportRoomKeysOpts, VerificationRequest } from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
@@ -42,6 +44,10 @@ import * as testData from "../../test-utils/test-data";
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
@@ -55,32 +61,7 @@ describe("RustCrypto", () => {
|
||||
);
|
||||
|
||||
it("should import and export keys", async () => {
|
||||
const someRoomKeys = [
|
||||
{
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
|
||||
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
|
||||
session_id: "iGQG5GaP1/B3dSH6zCQDQqrNuotrtQjVC7w1OsUDwbg",
|
||||
session_key:
|
||||
"AQAAAADaCbP2gdOy8jrhikjploKgSBaFSJ5rvHcziaADbwNEzeCSrfuAUlXvCvxik8kU+MfCHIi5arN2M7UM5rGKdzkHnkReoIByFkeMdbjKWk5SFpVQexcM74eDhBGj+ICkQqOgApfnEbSswrmreB0+MhHHyLStwW5fy5f8A9QW1sbPuohkBuRmj9fwd3Uh+swkA0KqzbqLa7UI1Qu8NTrFA8G4",
|
||||
sender_claimed_keys: {
|
||||
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
|
||||
},
|
||||
forwarding_curve25519_key_chain: [],
|
||||
},
|
||||
{
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
|
||||
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
|
||||
session_id: "P/Jy9Tog4CMtLseeS4Fe2AEXZov3k6cibcop/uyhr78",
|
||||
session_key:
|
||||
"AQAAAAATyAVm0c9c9DW9Od72MxvfSDYoysBw3C6yMJ3bYuTmssHN7yNGm59KCtKeFp2Y5qO7lvUmwOfSTvTASUb7HViE7Lt+Bvp5WiMTJ2Pv6m+N12ihyowV5lgtKFWI18Wxd0AugMTVQRwjBK6aMobf86NXWD2hiKm3N6kWbC0PXmqV7T/ycvU6IOAjLS7HnkuBXtgBF2aL95OnIm3KKf7soa+/",
|
||||
sender_claimed_keys: {
|
||||
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
|
||||
},
|
||||
forwarding_curve25519_key_chain: [],
|
||||
},
|
||||
];
|
||||
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
|
||||
let importTotal = 0;
|
||||
const opt: ImportRoomKeysOpts = {
|
||||
progressCallback: (stage) => {
|
||||
@@ -89,11 +70,11 @@ describe("RustCrypto", () => {
|
||||
};
|
||||
await rustCrypto.importRoomKeys(someRoomKeys, opt);
|
||||
|
||||
expect(importTotal).toBe(2);
|
||||
expect(importTotal).toBe(someRoomKeys.length);
|
||||
|
||||
const keys = await rustCrypto.exportRoomKeys();
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(2);
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
@@ -164,7 +145,7 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
it("getCrossSigningKeyId when there is no cross signing keys", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
|
||||
});
|
||||
@@ -347,6 +328,60 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDeviceVerified", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
async function getTestDevice(): Promise<Device> {
|
||||
const devices = await rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
|
||||
return devices.get(testData.TEST_USER_ID)!.get(testData.TEST_DEVICE_ID)!;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: {
|
||||
[testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
});
|
||||
// call onSyncCompleted to kick off the outgoingRequestLoop and download the device list.
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
// before the call, the device should be unverified.
|
||||
const device = await getTestDevice();
|
||||
expect(device.verified).toEqual(DeviceVerification.Unverified);
|
||||
});
|
||||
|
||||
it("should throw an error for an unknown device", async () => {
|
||||
await expect(rustCrypto.setDeviceVerified(testData.TEST_USER_ID, "xxy")).rejects.toThrow("Unknown device");
|
||||
});
|
||||
|
||||
it("should mark an unverified device as verified", async () => {
|
||||
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
// and confirm that the device is now verified
|
||||
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Verified);
|
||||
});
|
||||
|
||||
it("should mark a verified device as unverified", async () => {
|
||||
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Verified);
|
||||
|
||||
await rustCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID, false);
|
||||
expect((await getTestDevice()).verified).toEqual(DeviceVerification.Unverified);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceVerificationStatus", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
@@ -390,60 +425,39 @@ describe("RustCrypto", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false initially", async () => {
|
||||
jest.useFakeTimers();
|
||||
const prom = rustCrypto.userHasCrossSigningKeys();
|
||||
// the getIdentity() request should wait for a /keys/query request to complete, but times out after 1500ms
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await expect(prom).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if there is no cross-signing identity", async () => {
|
||||
// @ts-ignore private field
|
||||
const olmMachine = rustCrypto.olmMachine;
|
||||
|
||||
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
|
||||
// pick out the KeysQueryRequest, and respond to it with the device keys but *no* cross-signing keys.
|
||||
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
|
||||
await olmMachine.markRequestAsSent(
|
||||
req.id!,
|
||||
req.type,
|
||||
JSON.stringify({
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
|
||||
},
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error if the fetch fails", async () => {
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", 400);
|
||||
await expect(rustCrypto.userHasCrossSigningKeys()).rejects.toThrow("400 error");
|
||||
});
|
||||
|
||||
it("returns false if the user has no cross-signing keys", async () => {
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns true if OlmMachine has a cross-signing identity", async () => {
|
||||
// @ts-ignore private field
|
||||
const olmMachine = rustCrypto.olmMachine;
|
||||
it("returns true if the user has cross-signing keys", async () => {
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
|
||||
},
|
||||
...testData.SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
});
|
||||
|
||||
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
|
||||
// pick out the KeysQueryRequest, and respond to it with the cross-signing keys
|
||||
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
|
||||
await olmMachine.markRequestAsSent(
|
||||
req.id!,
|
||||
req.type,
|
||||
JSON.stringify({
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
|
||||
},
|
||||
...testData.SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
// ... and we should now have cross-signing keys.
|
||||
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -520,6 +534,41 @@ describe("RustCrypto", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get|storeSessionBackupPrivateKey", () => {
|
||||
it("can save and restore a key", async () => {
|
||||
const key = "testtesttesttesttesttesttesttest";
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key));
|
||||
const fetched = await rustCrypto.getSessionBackupPrivateKey();
|
||||
expect(new TextDecoder().decode(fetched!)).toEqual(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveSessionBackupVersion", () => {
|
||||
it("returns null", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
expect(await rustCrypto.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findVerificationRequestDMInProgress", () => {
|
||||
it("throws an error if the userId is not provided", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
expect(() => rustCrypto.findVerificationRequestDMInProgress(testData.TEST_ROOM_ID)).toThrow(
|
||||
"missing userId",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestVerificationDM", () => {
|
||||
it("send verification request to an unknown user", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(() =>
|
||||
rustCrypto.requestVerificationDM("@bob:example.com", testData.TEST_ROOM_ID),
|
||||
).rejects.toThrow("unknown userId @bob:example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
|
||||
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { RustVerificationRequest } from "../../../src/rust-crypto/verification";
|
||||
import { isVerificationEvent, RustVerificationRequest } from "../../../src/rust-crypto/verification";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { EventType, MatrixEvent, MsgType } from "../../../src";
|
||||
|
||||
describe("VerificationRequest", () => {
|
||||
describe("pending", () => {
|
||||
@@ -80,6 +81,40 @@ describe("VerificationRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerificationEvent", () => {
|
||||
it.each([
|
||||
[EventType.KeyVerificationCancel],
|
||||
[EventType.KeyVerificationDone],
|
||||
[EventType.KeyVerificationMac],
|
||||
[EventType.KeyVerificationStart],
|
||||
[EventType.KeyVerificationKey],
|
||||
[EventType.KeyVerificationReady],
|
||||
[EventType.KeyVerificationAccept],
|
||||
])("should return true with %s event", (eventType) => {
|
||||
const event = new MatrixEvent({
|
||||
type: eventType,
|
||||
});
|
||||
expect(isVerificationEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true with EventType.RoomMessage and MsgType.KeyVerificationRequest", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: MsgType.KeyVerificationRequest,
|
||||
},
|
||||
});
|
||||
expect(isVerificationEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false with a non verification event", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: EventType.RoomName,
|
||||
});
|
||||
expect(isVerificationEvent(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** build a RustVerificationRequest with default parameters */
|
||||
function makeTestRequest(
|
||||
inner?: RustSdkCryptoJs.VerificationRequest,
|
||||
@@ -87,7 +122,7 @@ function makeTestRequest(
|
||||
): RustVerificationRequest {
|
||||
inner ??= makeMockedInner();
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(inner, outgoingRequestProcessor, undefined);
|
||||
return new RustVerificationRequest(inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
|
||||
@@ -278,7 +278,7 @@ describe("IndexedDBStore", () => {
|
||||
workerFactory: () => worker,
|
||||
});
|
||||
await store.startup();
|
||||
await expect(store.destroy()).resolves;
|
||||
await store.destroy();
|
||||
expect(terminate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,8 +38,6 @@ import { mkMessage } from "../test-utils/test-utils";
|
||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
|
||||
// TODO: Fix types throughout
|
||||
|
||||
describe("utils", function () {
|
||||
describe("encodeParams", function () {
|
||||
it("should url encode and concat with &s", function () {
|
||||
|
||||
@@ -1607,7 +1607,7 @@ describe("Call", function () {
|
||||
it("throws when there is no error listener", async () => {
|
||||
call.off(CallEvent.Error, errorListener);
|
||||
|
||||
expect(call.placeVoiceCall()).rejects.toThrow();
|
||||
await expect(call.placeVoiceCall()).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("hasPeerConnection()", () => {
|
||||
|
||||
@@ -137,8 +137,8 @@ export enum PushRuleKind {
|
||||
|
||||
export enum RuleId {
|
||||
Master = ".m.rule.master",
|
||||
IsUserMention = ".org.matrix.msc3952.is_user_mention",
|
||||
IsRoomMention = ".org.matrix.msc3952.is_room_mention",
|
||||
IsUserMention = ".m.rule.is_user_mention",
|
||||
IsRoomMention = ".m.rule.is_room_mention",
|
||||
ContainsDisplayName = ".m.rule.contains_display_name",
|
||||
ContainsUserName = ".m.rule.contains_user_name",
|
||||
AtRoomNotification = ".m.rule.roomnotif",
|
||||
|
||||
Vendored
+16
@@ -96,4 +96,20 @@ declare global {
|
||||
// but we still need this for MatrixCall::getRidOfRTXCodecs()
|
||||
setCodecPreferences(codecs: RTCRtpCodecCapability[]): void;
|
||||
}
|
||||
|
||||
interface RequestInit {
|
||||
/**
|
||||
* Specifies the priority of the fetch request relative to other requests of the same type.
|
||||
* Must be one of the following strings:
|
||||
* high: A high priority fetch request relative to other requests of the same type.
|
||||
* low: A low priority fetch request relative to other requests of the same type.
|
||||
* auto: Automatically determine the priority of the fetch request relative to other requests of the same type (default).
|
||||
*
|
||||
* @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fetch-priority-attribute
|
||||
* @see https://github.com/microsoft/TypeScript/issues/54472
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#browser_compatibility
|
||||
* Not yet supported in Safari or Firefox
|
||||
*/
|
||||
priority?: "high" | "low" | "auto";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,18 @@ export interface IJoinRoomOpts {
|
||||
viaServers?: string[];
|
||||
}
|
||||
|
||||
export interface KnockRoomOpts {
|
||||
/**
|
||||
* The reason for the knock.
|
||||
*/
|
||||
reason?: string;
|
||||
|
||||
/**
|
||||
* The server names to try and knock through in addition to those that are automatically chosen.
|
||||
*/
|
||||
viaServers?: string | string[];
|
||||
}
|
||||
|
||||
export interface IRedactOpts {
|
||||
reason?: string;
|
||||
/**
|
||||
|
||||
+89
-5
@@ -15,11 +15,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { SigningKey } from "oidc-client-ts";
|
||||
|
||||
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixError, Method, timeoutSignal } from "./http-api";
|
||||
import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate";
|
||||
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
|
||||
import {
|
||||
ValidatedIssuerConfig,
|
||||
ValidatedIssuerMetadata,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "./oidc/validate";
|
||||
import { OidcError } from "./oidc/error";
|
||||
import { MINIMUM_MATRIX_VERSION } from "./version-support";
|
||||
|
||||
// Dev note: Auto discovery is part of the spec.
|
||||
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
|
||||
@@ -42,6 +51,9 @@ enum AutoDiscoveryError {
|
||||
InvalidIs = "Invalid identity server discovery response",
|
||||
MissingWellknown = "No .well-known JSON file found",
|
||||
InvalidJson = "Invalid JSON",
|
||||
HomeserverTooOld = "The homeserver does not meet the minimum version requirements",
|
||||
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
|
||||
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
|
||||
}
|
||||
|
||||
interface AutoDiscoveryState {
|
||||
@@ -50,12 +62,26 @@ interface AutoDiscoveryState {
|
||||
}
|
||||
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
|
||||
|
||||
/**
|
||||
* @deprecated in favour of OidcClientConfig
|
||||
*/
|
||||
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
|
||||
metadata: ValidatedIssuerMetadata;
|
||||
signingKeys?: SigningKey[];
|
||||
}
|
||||
|
||||
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
|
||||
"m.homeserver": WellKnownConfig;
|
||||
"m.identity_server": WellKnownConfig;
|
||||
"m.authentication"?: DelegatedAuthConfig | AutoDiscoveryState;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +112,8 @@ export class AutoDiscovery {
|
||||
|
||||
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
|
||||
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
|
||||
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
|
||||
|
||||
/**
|
||||
@@ -177,7 +205,7 @@ export class AutoDiscovery {
|
||||
|
||||
// Step 3: Make sure the homeserver URL points to a homeserver.
|
||||
const hsVersions = await this.fetchWellKnownObject<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
|
||||
if (!hsVersions?.raw?.["versions"]) {
|
||||
if (!hsVersions || !Array.isArray(hsVersions.raw?.["versions"])) {
|
||||
logger.error("Invalid /versions response");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
|
||||
|
||||
@@ -188,6 +216,18 @@ export class AutoDiscovery {
|
||||
return Promise.resolve(clientConfig);
|
||||
}
|
||||
|
||||
// Step 3.1: Non-spec check to ensure the server will actually work for us
|
||||
if (!hsVersions.raw!["versions"].includes(MINIMUM_MATRIX_VERSION)) {
|
||||
logger.error("Homeserver does not meet minimum version requirements");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_HOMESERVER_TOO_OLD;
|
||||
|
||||
// Supply the base_url to the caller because they may be ignoring liveliness
|
||||
// errors, like this one.
|
||||
clientConfig["m.homeserver"].base_url = hsUrl;
|
||||
|
||||
return Promise.resolve(clientConfig);
|
||||
}
|
||||
|
||||
// Step 4: Now that the homeserver looks valid, update our client config.
|
||||
clientConfig["m.homeserver"] = {
|
||||
state: AutoDiscovery.SUCCESS,
|
||||
@@ -262,7 +302,7 @@ export class AutoDiscovery {
|
||||
}
|
||||
});
|
||||
|
||||
const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown);
|
||||
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
|
||||
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
|
||||
|
||||
// Step 8: Give the config to the caller (finally)
|
||||
@@ -271,6 +311,7 @@ export class AutoDiscovery {
|
||||
|
||||
/**
|
||||
* Validate delegated auth configuration
|
||||
* @deprecated use discoverAndValidateAuthenticationConfig
|
||||
* - m.authentication config is present and valid
|
||||
* - delegated auth issuer openid-configuration is reachable
|
||||
* - delegated auth issuer openid-configuration is configured correctly for us
|
||||
@@ -284,7 +325,8 @@ export class AutoDiscovery {
|
||||
wellKnown: IClientWellKnown,
|
||||
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
|
||||
try {
|
||||
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
|
||||
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
|
||||
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
|
||||
|
||||
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
|
||||
homeserverAuthenticationConfig.issuer,
|
||||
@@ -319,6 +361,48 @@ export class AutoDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate delegated auth configuration
|
||||
* - m.authentication config is present and valid
|
||||
* - delegated auth issuer openid-configuration is reachable
|
||||
* - delegated auth issuer openid-configuration is configured correctly for us
|
||||
* When successful, validated authentication metadata and optionally signing keys will be returned
|
||||
* Any errors are caught, and AutoDiscoveryState returned with error
|
||||
* @param wellKnown - configuration object as returned
|
||||
* by the .well-known auto-discovery endpoint
|
||||
* @returns Config or failure result
|
||||
*/
|
||||
public static async discoverAndValidateAuthenticationConfig(
|
||||
wellKnown: IClientWellKnown,
|
||||
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
|
||||
try {
|
||||
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
|
||||
const result = await discoverAndValidateAuthenticationConfig(authentication);
|
||||
|
||||
// include this for backwards compatibility
|
||||
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
|
||||
|
||||
const response = {
|
||||
state: AutoDiscoveryAction.SUCCESS,
|
||||
error: null,
|
||||
...validatedIssuerConfig,
|
||||
...result,
|
||||
};
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message as unknown as OidcError;
|
||||
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
|
||||
|
||||
const state =
|
||||
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
|
||||
|
||||
return {
|
||||
state,
|
||||
error: errorType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to automatically discover client configuration information
|
||||
* prior to logging in. Such information includes the homeserver URL
|
||||
|
||||
+194
-271
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto";
|
||||
import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
|
||||
import { ISyncStateData, SetPresence, SyncApi, SyncApiOptions, SyncState } from "./sync";
|
||||
import {
|
||||
EventStatus,
|
||||
IContent,
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
UploadResponse,
|
||||
HTTPError,
|
||||
IRequestOpts,
|
||||
Body,
|
||||
} from "./http-api";
|
||||
import {
|
||||
Crypto,
|
||||
@@ -111,7 +112,7 @@ import * as ContentHelpers from "./content-helpers";
|
||||
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
|
||||
import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
|
||||
import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
|
||||
import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
|
||||
import { IPowerLevelsContent, RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
|
||||
import {
|
||||
IAddThreePidOnlyBody,
|
||||
IBindThreePidBody,
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
IFilterResponse,
|
||||
ITagsResponse,
|
||||
IStatusResponse,
|
||||
KnockRoomOpts,
|
||||
} from "./@types/requests";
|
||||
import {
|
||||
EventType,
|
||||
@@ -208,7 +210,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
import { BootstrapCrossSigningOpts, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
|
||||
import { DeviceInfoMap } from "./crypto/DeviceList";
|
||||
import {
|
||||
AddSecretStorageKeyOpts,
|
||||
@@ -522,13 +524,8 @@ export interface Capabilities {
|
||||
[UNSTABLE_MSC3882_CAPABILITY.altName]?: IMSC3882GetLoginTokenCapability;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface ICrossSigningKey {
|
||||
keys: { [algorithm: string]: string };
|
||||
signatures?: ISignatures;
|
||||
usage: string[];
|
||||
user_id: string;
|
||||
}
|
||||
/** @deprecated prefer {@link CrossSigningKeyInfo}. */
|
||||
export type ICrossSigningKey = CrossSigningKeyInfo;
|
||||
|
||||
enum CrossSigningKeyType {
|
||||
MasterKey = "master_key",
|
||||
@@ -1289,7 +1286,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
baseUrl: opts.baseUrl,
|
||||
idBaseUrl: opts.idBaseUrl,
|
||||
accessToken: opts.accessToken,
|
||||
prefix: ClientPrefix.R0,
|
||||
prefix: ClientPrefix.V3,
|
||||
onlyData: true,
|
||||
extraParams: opts.queryParams,
|
||||
localTimeoutMs: opts.localTimeoutMs,
|
||||
@@ -2012,12 +2009,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Set whether this client is a guest account. <b>This method is experimental
|
||||
* and may change without warning.</b>
|
||||
* @param guest - True if this is a guest account.
|
||||
* @experimental if the token is a macaroon, it should be encoded in it that it is a 'guest'
|
||||
* access token, which means that the SDK can determine this entirely without
|
||||
* the dev manually flipping this flag.
|
||||
*/
|
||||
public setGuest(guest: boolean): void {
|
||||
// EXPERIMENTAL:
|
||||
// If the token is a macaroon, it should be encoded in it that it is a 'guest'
|
||||
// access token, which means that the SDK can determine this entirely without
|
||||
// the dev manually flipping this flag.
|
||||
this.isGuestAccount = guest;
|
||||
}
|
||||
|
||||
@@ -2241,15 +2237,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.cryptoCallbacks,
|
||||
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
|
||||
);
|
||||
rustCrypto.supportedVerificationMethods = this.verificationMethods;
|
||||
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
|
||||
|
||||
this.cryptoBackend = rustCrypto;
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
|
||||
this.on(ClientEvent.Event, (event) => {
|
||||
rustCrypto.onLiveEventFromSync(event);
|
||||
});
|
||||
|
||||
// re-emit the events emitted by the crypto impl
|
||||
this.reEmitter.reEmit(rustCrypto, [CryptoEvent.VerificationRequestReceived]);
|
||||
this.reEmitter.reEmit(rustCrypto, [
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
CryptoEvent.UserTrustStatusChanged,
|
||||
CryptoEvent.KeyBackupStatus,
|
||||
CryptoEvent.KeyBackupSessionsRemaining,
|
||||
CryptoEvent.KeyBackupFailed,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2439,6 +2444,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @returns resolves to a VerificationRequest
|
||||
* when the request has been sent to the other party.
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.requestVerificationDM}.
|
||||
*/
|
||||
public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
|
||||
if (!this.crypto) {
|
||||
@@ -2678,12 +2685,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Check the copy of our cross-signing key that we have in the device list and
|
||||
* see if we can get the private key. If so, mark it as trusted.
|
||||
* @param opts - ICheckOwnCrossSigningTrustOpts object
|
||||
*
|
||||
* @deprecated Unneeded for the new crypto
|
||||
*/
|
||||
public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.checkOwnCrossSigningTrust(opts);
|
||||
return this.cryptoBackend.checkOwnCrossSigningTrust(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2854,12 +2863,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Object with public key metadata, encoded private
|
||||
* recovery key which should be disposed of after displaying to the user,
|
||||
* and raw private key to avoid round tripping if needed.
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.createRecoveryKeyFromPassphrase | `CryptoApi.createRecoveryKeyFromPassphrase`}.
|
||||
*/
|
||||
public createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.createRecoveryKeyFromPassphrase(password);
|
||||
return this.cryptoBackend.createRecoveryKeyFromPassphrase(password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2874,7 +2885,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* return true.
|
||||
*
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
|
||||
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}.
|
||||
*/
|
||||
public isSecretStorageReady(): Promise<boolean> {
|
||||
if (!this.cryptoBackend) {
|
||||
@@ -2896,13 +2907,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* - migrates Secure Secret Storage to use the latest algorithm, if an outdated
|
||||
* algorithm is found
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi#bootstrapSecretStorage}.
|
||||
* @deprecated Use {@link CryptoApi.bootstrapSecretStorage | `CryptoApi.bootstrapSecretStorage`}.
|
||||
*/
|
||||
public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.bootstrapSecretStorage(opts);
|
||||
return this.cryptoBackend.bootstrapSecretStorage(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3244,6 +3255,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* getKeyBackupVersion) in backupInfo and
|
||||
* trust information (as returned by isKeyBackupTrusted)
|
||||
* in trustInfo.
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
|
||||
if (!this.crypto) {
|
||||
@@ -3279,6 +3292,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* @param info - key backup info dict from getKeyBackupVersion()
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.isKeyBackupTrusted | `CryptoApi.isKeyBackupTrusted`}.
|
||||
*/
|
||||
public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> {
|
||||
if (!this.crypto) {
|
||||
@@ -3291,6 +3306,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns true if the client is configured to back up keys to
|
||||
* the server, otherwise false. If we haven't completed a successful check
|
||||
* of key backup status yet, returns null.
|
||||
*
|
||||
* @deprecated Prefer direct access to {@link CryptoApi.getActiveSessionBackupVersion}:
|
||||
*
|
||||
* ```javascript
|
||||
* let enabled = (await client.getCrypto().getActiveSessionBackupVersion()) !== null;
|
||||
* ```
|
||||
*/
|
||||
public getKeyBackupEnabled(): boolean | null {
|
||||
if (!this.crypto) {
|
||||
@@ -3305,6 +3326,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param info - Backup information object as returned by getKeyBackupVersion
|
||||
* @returns Promise which resolves when complete.
|
||||
*
|
||||
* @deprecated Do not call this directly. Instead call {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
@@ -3410,9 +3433,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
await this.crypto.crossSigningInfo.signObject(data.auth_data, "master");
|
||||
}
|
||||
|
||||
const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data);
|
||||
|
||||
// We could assume everything's okay and enable directly, but this ensures
|
||||
// we run the same signature verification that will be used for future
|
||||
@@ -3433,6 +3454,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// If we're currently backing up to this backup... stop.
|
||||
// (We start using it automatically in createKeyBackupVersion
|
||||
// so this is symmetrical).
|
||||
// TODO: convert this to use crypto.getActiveSessionBackupVersion. And actually check the version.
|
||||
if (this.crypto.backupManager.version) {
|
||||
this.crypto.backupManager.disableKeyBackup();
|
||||
}
|
||||
@@ -3718,10 +3740,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
backupInfo: IKeyBackupInfo,
|
||||
opts?: IKeyBackupRestoreOpts,
|
||||
): Promise<IKeyBackupRestoreResult> {
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
const privKey = await this.crypto.getSessionBackupPrivateKey();
|
||||
const privKey = await this.cryptoBackend.getSessionBackupPrivateKey();
|
||||
if (!privKey) {
|
||||
throw new Error("Couldn't get key");
|
||||
}
|
||||
@@ -3759,7 +3781,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const cacheCompleteCallback = opts?.cacheCompleteCallback;
|
||||
const progressCallback = opts?.progressCallback;
|
||||
|
||||
if (!this.crypto) {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
|
||||
@@ -3782,9 +3804,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
|
||||
}
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error(`restoreKeyBackup expects Uint8Array, got ${privKey}`);
|
||||
}
|
||||
// Cache the key, if possible.
|
||||
// This is async.
|
||||
this.crypto
|
||||
this.cryptoBackend
|
||||
.storeSessionBackupPrivateKey(privKey)
|
||||
.catch((e) => {
|
||||
logger.warn("Error caching session backup key:", e);
|
||||
@@ -3841,7 +3867,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
algorithm.free();
|
||||
}
|
||||
|
||||
await this.getCrypto()?.importRoomKeys(keys, {
|
||||
await this.cryptoBackend.importRoomKeys(keys, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
source: "backup",
|
||||
@@ -3904,7 +3930,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public getMediaConfig(): Promise<IMediaConfig> {
|
||||
return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
|
||||
prefix: MediaPrefix.R0,
|
||||
prefix: MediaPrefix.V3,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4139,6 +4165,34 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return syncRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Knock a room. If you have already knocked the room, this will no-op.
|
||||
* @param roomIdOrAlias - The room ID or room alias to knock.
|
||||
* @param opts - Options when knocking the room.
|
||||
* @returns Promise which resolves: `{room_id: {string}}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public knockRoom(roomIdOrAlias: string, opts: KnockRoomOpts = {}): Promise<{ room_id: string }> {
|
||||
const room = this.getRoom(roomIdOrAlias);
|
||||
if (room?.hasMembershipState(this.credentials.userId!, "knock")) {
|
||||
return Promise.resolve({ room_id: room.roomId });
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
|
||||
|
||||
const queryParams: Record<string, string | string[]> = {};
|
||||
if (opts.viaServers) {
|
||||
queryParams.server_name = opts.viaServers;
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (opts.reason) {
|
||||
body.reason = opts.reason;
|
||||
}
|
||||
|
||||
return this.http.authedRequest(Method.Post, path, queryParams, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an event. Will also retry any to-device messages waiting to be sent.
|
||||
* @param event - The event to resend.
|
||||
@@ -4256,24 +4310,48 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* Set a power level to one or multiple users.
|
||||
* Will apply changes atop of current power level event from local state if running & synced, falling back
|
||||
* to fetching latest from the `/state/` API.
|
||||
* @param roomId - the room to update power levels in
|
||||
* @param userId - the ID of the user or users to update power levels of
|
||||
* @param powerLevel - the numeric power level to update given users to
|
||||
* @param event - deprecated and no longer used.
|
||||
* @returns Promise which resolves: to an ISendEventResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setPowerLevel(
|
||||
public async setPowerLevel(
|
||||
roomId: string,
|
||||
userId: string | string[],
|
||||
powerLevel: number | undefined,
|
||||
event: MatrixEvent | null,
|
||||
/**
|
||||
* @deprecated no longer needed, unused.
|
||||
*/
|
||||
event?: MatrixEvent | null,
|
||||
): Promise<ISendEventResponse> {
|
||||
let content = {
|
||||
users: {} as Record<string, number>,
|
||||
};
|
||||
if (event?.getType() === EventType.RoomPowerLevels) {
|
||||
// take a copy of the content to ensure we don't corrupt
|
||||
// existing client state with a failed power level change
|
||||
content = utils.deepCopy(event.getContent());
|
||||
let content: IPowerLevelsContent | undefined;
|
||||
if (this.clientRunning && this.isInitialSyncComplete()) {
|
||||
content = this.getRoom(roomId)?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
|
||||
}
|
||||
if (!content) {
|
||||
try {
|
||||
content = await this.getStateEvent(roomId, EventType.RoomPowerLevels, "");
|
||||
} catch (e) {
|
||||
// It is possible for a Matrix room to not have a power levels event
|
||||
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
|
||||
content = {};
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// take a copy of the content to ensure we don't corrupt
|
||||
// existing client state with a failed power level change
|
||||
content = utils.deepCopy(content);
|
||||
|
||||
if (!content?.users) {
|
||||
content.users = {};
|
||||
}
|
||||
const users = Array.isArray(userId) ? userId : [userId];
|
||||
for (const user of users) {
|
||||
if (powerLevel == null) {
|
||||
@@ -4283,10 +4361,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
|
||||
$roomId: roomId,
|
||||
});
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content);
|
||||
return this.sendStateEvent(roomId, EventType.RoomPowerLevels, content, "");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4382,7 +4457,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
private sendCompleteEvent(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventObject: any,
|
||||
eventObject: Partial<IEvent>,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!txnId) {
|
||||
@@ -4959,7 +5034,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> {
|
||||
public async sendReceipt(
|
||||
event: MatrixEvent,
|
||||
receiptType: ReceiptType,
|
||||
body?: Record<string, any>,
|
||||
unthreaded = false,
|
||||
): Promise<{}> {
|
||||
if (this.isGuest()) {
|
||||
return Promise.resolve({}); // guests cannot send receipts so don't bother.
|
||||
}
|
||||
@@ -4970,10 +5050,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$eventId: event.getId()!,
|
||||
});
|
||||
|
||||
if (!unthreaded) {
|
||||
const isThread = !!event.threadRootId;
|
||||
if (!unthreaded && this.supportsThreads()) {
|
||||
// XXX: the spec currently says a threaded read receipt can be sent for the root of a thread,
|
||||
// but in practice this isn't possible and the spec needs updating.
|
||||
const isThread =
|
||||
!!event.threadRootId &&
|
||||
// A thread cannot be just a thread root and a thread root can only be read in the main timeline
|
||||
!event.isThreadRoot &&
|
||||
// Similarly non-thread relations upon the thread root (reactions, edits) should also be for the main timeline.
|
||||
event.isRelation() &&
|
||||
(event.isRelation(THREAD_RELATION_TYPE.name) || event.relationEventId !== event.threadRootId);
|
||||
body = {
|
||||
...body,
|
||||
// Only thread replies should define a specific thread. Thread roots can only be read in the main timeline.
|
||||
thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE,
|
||||
};
|
||||
}
|
||||
@@ -5096,7 +5185,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
prefix: MediaPrefix.R0,
|
||||
prefix: MediaPrefix.V3,
|
||||
priority: "low",
|
||||
},
|
||||
);
|
||||
// TODO: Expire the URL preview cache sometimes
|
||||
@@ -5117,7 +5207,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$roomId: roomId,
|
||||
$userId: this.getUserId()!,
|
||||
});
|
||||
const data: any = {
|
||||
const data: QueryDict = {
|
||||
typing: isTyping,
|
||||
};
|
||||
if (isTyping) {
|
||||
@@ -5262,7 +5352,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
address: address,
|
||||
};
|
||||
|
||||
if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
|
||||
if (this.identityServer?.getAccessToken) {
|
||||
const identityAccessToken = await this.identityServer.getAccessToken();
|
||||
if (identityAccessToken) {
|
||||
params["id_access_token"] = identityAccessToken;
|
||||
@@ -5309,7 +5399,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
const populationResults: { [roomId: string]: Error } = {};
|
||||
const promises: Promise<any>[] = [];
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
const doLeave = (roomId: string): Promise<void> => {
|
||||
return this.leave(roomId)
|
||||
@@ -5510,6 +5600,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the set_presence value to be used for subsequent calls to the Sync API.
|
||||
* This has an advantage over calls to the PUT /presence API in that it
|
||||
* doesn't clobber status_msg set by other devices.
|
||||
* @param presence - the presence to specify to set_presence of sync calls
|
||||
*/
|
||||
public async setSyncPresence(presence?: SetPresence): Promise<void> {
|
||||
this.syncApi?.setPresence(presence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param opts - Options to apply
|
||||
* @returns Promise which resolves
|
||||
@@ -5902,7 +6002,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("getLatestTimeline only supports room timelines");
|
||||
}
|
||||
|
||||
let event;
|
||||
let event: IRoomEvent | undefined;
|
||||
if (timelineSet.threadListType !== null) {
|
||||
const res = await this.createThreadListMessagesRequest(
|
||||
timelineSet.room.roomId,
|
||||
@@ -6044,7 +6144,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const opts = {
|
||||
prefix:
|
||||
Thread.hasServerSideListSupport === FeatureSupport.Stable
|
||||
? "/_matrix/client/v1"
|
||||
? ClientPrefix.V1
|
||||
: "/_matrix/client/unstable/org.matrix.msc3856",
|
||||
};
|
||||
|
||||
@@ -6361,7 +6461,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
"",
|
||||
);
|
||||
|
||||
let readPromise: Promise<any> = Promise.resolve<any>(undefined);
|
||||
let readPromise: Promise<unknown> = Promise.resolve();
|
||||
if (opts.allowRead) {
|
||||
readPromise = this.sendStateEvent(
|
||||
roomId,
|
||||
@@ -6566,24 +6666,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
private async requestTokenFromEndpoint<T extends IRequestTokenResponse>(
|
||||
endpoint: string,
|
||||
params: Record<string, any>,
|
||||
params: QueryDict,
|
||||
): Promise<T> {
|
||||
const postParams = Object.assign({}, params);
|
||||
|
||||
// If the HS supports separate add and bind, then requestToken endpoints
|
||||
// don't need an IS as they are all validated by the HS directly.
|
||||
if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) {
|
||||
const idServerUrl = new URL(this.idBaseUrl);
|
||||
postParams.id_server = idServerUrl.host;
|
||||
|
||||
if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
|
||||
const identityAccessToken = await this.identityServer.getAccessToken();
|
||||
if (identityAccessToken) {
|
||||
postParams.id_access_token = identityAccessToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.request(Method.Post, endpoint, undefined, postParams);
|
||||
}
|
||||
|
||||
@@ -7288,78 +7374,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return versions && versions.includes(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if it supports members lazy loading
|
||||
* @returns true if server supports lazy loading
|
||||
*/
|
||||
public async doesServerSupportLazyLoading(): Promise<boolean> {
|
||||
const response = await this.getVersions();
|
||||
if (!response) return false;
|
||||
|
||||
const versions = response["versions"];
|
||||
const unstableFeatures = response["unstable_features"];
|
||||
|
||||
return (
|
||||
(versions && versions.includes("r0.5.0")) || (unstableFeatures && unstableFeatures["m.lazy_load_members"])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if the `id_server` parameter is required
|
||||
* when registering with an 3pid, adding a 3pid or resetting password.
|
||||
* @returns true if id_server parameter is required
|
||||
*/
|
||||
public async doesServerRequireIdServerParam(): Promise<boolean> {
|
||||
const response = await this.getVersions();
|
||||
if (!response) return true;
|
||||
|
||||
const versions = response["versions"];
|
||||
|
||||
// Supporting r0.6.0 is the same as having the flag set to false
|
||||
if (versions && versions.includes("r0.6.0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unstableFeatures = response["unstable_features"];
|
||||
if (!unstableFeatures) return true;
|
||||
if (unstableFeatures["m.require_identity_server"] === undefined) {
|
||||
return true;
|
||||
} else {
|
||||
return unstableFeatures["m.require_identity_server"];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if the `id_access_token` parameter can be safely
|
||||
* passed to the homeserver. Some homeservers may trigger errors if they are not
|
||||
* prepared for the new parameter.
|
||||
* @returns true if id_access_token can be sent
|
||||
*/
|
||||
public async doesServerAcceptIdentityAccessToken(): Promise<boolean> {
|
||||
const response = await this.getVersions();
|
||||
if (!response) return false;
|
||||
|
||||
const versions = response["versions"];
|
||||
const unstableFeatures = response["unstable_features"];
|
||||
return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.id_access_token"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if it supports separate 3PID add and bind functions.
|
||||
* This affects the sequence of API calls clients should use for these operations,
|
||||
* so it's helpful to be able to check for support.
|
||||
* @returns true if separate functions are supported
|
||||
*/
|
||||
public async doesServerSupportSeparateAddAndBind(): Promise<boolean> {
|
||||
const response = await this.getVersions();
|
||||
if (!response) return false;
|
||||
|
||||
const versions = response["versions"];
|
||||
const unstableFeatures = response["unstable_features"];
|
||||
|
||||
return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if it lists support for an unstable feature
|
||||
* in the /versions response
|
||||
@@ -7431,14 +7445,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the server to see if it supports the MSC2457 `logout_devices` parameter when setting password
|
||||
* @returns true if server supports the `logout_devices` parameter
|
||||
*/
|
||||
public doesServerSupportLogoutDevices(): Promise<boolean> {
|
||||
return this.isVersionSupported("r0.6.1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get if lazy loading members is being used.
|
||||
* @returns Whether or not members are lazy loaded by this client
|
||||
@@ -7823,18 +7829,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param relayState - URL Callback after SAML2 Authentication
|
||||
* @returns Promise which resolves to a LoginResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
* @deprecated this isn't in the Matrix spec anymore
|
||||
*/
|
||||
public loginWithSAML2(relayState: string): Promise<LoginResponse> {
|
||||
return this.login("m.login.saml2", {
|
||||
relay_state: relayState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param redirectUrl - The URL to redirect to after the HS
|
||||
* authenticates with CAS.
|
||||
@@ -7864,7 +7858,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
[SSO_ACTION_PARAM.unstable!]: action,
|
||||
};
|
||||
|
||||
return this.http.getUrl(url, params, ClientPrefix.R0).href;
|
||||
return this.http.getUrl(url, params).href;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7916,8 +7910,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* to false.
|
||||
* @returns Promise which resolves: On success, the empty object
|
||||
*/
|
||||
public deactivateAccount(auth?: any, erase?: boolean): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
|
||||
const body: any = {};
|
||||
public deactivateAccount(
|
||||
auth?: AuthDict,
|
||||
erase?: boolean,
|
||||
): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
|
||||
const body: Body = {};
|
||||
if (auth) {
|
||||
body.auth = auth;
|
||||
}
|
||||
@@ -7979,13 +7976,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$loginType: loginType,
|
||||
});
|
||||
|
||||
return this.http.getUrl(
|
||||
path,
|
||||
{
|
||||
session: authSessionId,
|
||||
},
|
||||
ClientPrefix.R0,
|
||||
).href;
|
||||
return this.http.getUrl(path, {
|
||||
session: authSessionId,
|
||||
}).href;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8000,11 +7993,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// inject the id_access_token if inviting 3rd party addresses
|
||||
const invitesNeedingToken = (options.invite_3pid || []).filter((i) => !i.id_access_token);
|
||||
if (
|
||||
invitesNeedingToken.length > 0 &&
|
||||
this.identityServer?.getAccessToken &&
|
||||
(await this.doesServerAcceptIdentityAccessToken())
|
||||
) {
|
||||
if (invitesNeedingToken.length > 0 && this.identityServer?.getAccessToken) {
|
||||
const identityAccessToken = await this.identityServer.getAccessToken();
|
||||
if (identityAccessToken) {
|
||||
for (const invite of invitesNeedingToken) {
|
||||
@@ -8155,7 +8144,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public sendStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: any,
|
||||
content: IContent,
|
||||
stateKey = "",
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<ISendEventResponse> {
|
||||
@@ -8363,30 +8352,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, { visibility });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the visbility of a room bridged to a 3rd party network in
|
||||
* the current HS's room directory.
|
||||
* @param networkId - the network ID of the 3rd party
|
||||
* instance under which this room is published under.
|
||||
* @param visibility - "public" to make the room visible
|
||||
* in the public directory, or "private" to make
|
||||
* it invisible.
|
||||
* @returns Promise which resolves: result object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setRoomDirectoryVisibilityAppService(
|
||||
networkId: string,
|
||||
roomId: string,
|
||||
visibility: "public" | "private",
|
||||
): Promise<any> {
|
||||
// TODO: Types
|
||||
const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
|
||||
$networkId: networkId,
|
||||
$roomId: roomId,
|
||||
});
|
||||
return this.http.authedRequest(Method.Put, path, undefined, { visibility: visibility });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the user directory with a term matching user IDs, display names and domains.
|
||||
* @param term - the term with which to search.
|
||||
@@ -8395,7 +8360,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: an array of results.
|
||||
*/
|
||||
public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise<IUserDirectoryResponse> {
|
||||
const body: any = {
|
||||
const body: Body = {
|
||||
search_term: term,
|
||||
};
|
||||
|
||||
@@ -8469,33 +8434,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Get, "/account/3pid");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a 3PID to your homeserver account and optionally bind it to an identity
|
||||
* server as well. An identity server is required as part of the `creds` object.
|
||||
*
|
||||
* This API is deprecated, and you should instead use `addThreePidOnly`
|
||||
* for homeservers that support it.
|
||||
*
|
||||
* @returns Promise which resolves: on success
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public addThreePid(creds: any, bind: boolean): Promise<any> {
|
||||
// TODO: Types
|
||||
const path = "/account/3pid";
|
||||
const data = {
|
||||
threePidCreds: creds,
|
||||
bind: bind,
|
||||
};
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a 3PID to your homeserver account. This API does not use an identity
|
||||
* server, as the homeserver is expected to handle 3PID ownership validation.
|
||||
*
|
||||
* You can check whether a homeserver supports this API via
|
||||
* `doesServerSupportSeparateAddAndBind`.
|
||||
*
|
||||
* @param data - A object with 3PID validation data from having called
|
||||
* `account/3pid/<medium>/requestToken` on the homeserver.
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
@@ -8503,8 +8445,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")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8512,9 +8453,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* identity server handles 3PID ownership validation and the homeserver records
|
||||
* the new binding to track where all 3PIDs for the account are bound.
|
||||
*
|
||||
* You can check whether a homeserver supports this API via
|
||||
* `doesServerSupportSeparateAddAndBind`.
|
||||
*
|
||||
* @param data - A object with 3PID validation data from having called
|
||||
* `validate/<medium>/requestToken` on the identity server. It should also
|
||||
* contain `id_server` and `id_access_token` fields as well.
|
||||
@@ -8523,8 +8461,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public async bindThreePid(data: IBindThreePidBody): Promise<{}> {
|
||||
const path = "/account/3pid/bind";
|
||||
const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8549,8 +8486,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
address,
|
||||
id_server: this.getIdentityServerUrl(true),
|
||||
};
|
||||
const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
|
||||
return this.http.authedRequest(Method.Post, path, undefined, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8640,7 +8576,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$device_id: deviceId,
|
||||
});
|
||||
|
||||
const body: any = {};
|
||||
const body: Body = {};
|
||||
|
||||
if (auth) {
|
||||
body.auth = auth;
|
||||
@@ -8658,7 +8594,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public deleteMultipleDevices(devices: string[], auth?: AuthDict): Promise<{}> {
|
||||
const body: any = { devices };
|
||||
const body: Body = { devices };
|
||||
|
||||
if (auth) {
|
||||
body.auth = auth;
|
||||
@@ -8835,7 +8771,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
{ body, next_batch: nextBatch }: { body: ISearchRequestBody; next_batch?: string },
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ISearchResponse> {
|
||||
const queryParams: any = {};
|
||||
const queryParams: QueryDict = {};
|
||||
if (nextBatch) {
|
||||
queryParams.next_batch = nextBatch;
|
||||
}
|
||||
@@ -8858,9 +8794,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> {
|
||||
return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9455,7 +9389,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* response to getThirdpartyProtocols()
|
||||
* @returns Promise which resolves to the result object
|
||||
*/
|
||||
public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> {
|
||||
public getThirdpartyUser(protocol: string, params?: QueryDict): Promise<IThirdPartyUser[]> {
|
||||
const path = utils.encodeUri("/thirdparty/user/$protocol", {
|
||||
$protocol: protocol,
|
||||
});
|
||||
@@ -9631,7 +9565,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
proxyBaseUrl?: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<MSC3575SlidingSyncResponse> {
|
||||
const qps: Record<string, any> = {};
|
||||
const qps: QueryDict = {};
|
||||
if (req.pos) {
|
||||
qps.pos = req.pos;
|
||||
delete req.pos;
|
||||
@@ -9705,6 +9639,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param toStartOfTimeline - the direction
|
||||
*/
|
||||
public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
if (!this.supportsThreads()) return;
|
||||
room.processThreadRoots(threadedEvents, toStartOfTimeline);
|
||||
}
|
||||
|
||||
@@ -9795,19 +9730,8 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
|
||||
const room = cli.getRoom(event.getRoomId());
|
||||
if (!room || !ourUserId || !eventId) return;
|
||||
|
||||
const oldActions = event.getPushActions();
|
||||
const actions = cli.getPushActionsForEvent(event, true);
|
||||
|
||||
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
|
||||
|
||||
const currentHighlightCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
|
||||
|
||||
// Ensure the unread counts are kept up to date if the event is encrypted
|
||||
// We also want to make sure that the notification count goes up if we already
|
||||
// have encrypted events to avoid other code from resetting 'highlight' to zero.
|
||||
const oldHighlight = !!oldActions?.tweaks?.highlight;
|
||||
const newHighlight = !!actions?.tweaks?.highlight;
|
||||
|
||||
let hasReadEvent;
|
||||
if (isThreadEvent) {
|
||||
const thread = room.getThread(event.threadRootId);
|
||||
@@ -9829,13 +9753,17 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHighlight !== newHighlight || currentHighlightCount > 0) {
|
||||
const actions = cli.getPushActionsForEvent(event, true);
|
||||
|
||||
// Ensure the unread counts are kept up to date if the event is encrypted
|
||||
// We also want to make sure that the notification count goes up if we already
|
||||
// have encrypted events to avoid other code from resetting 'highlight' to zero.
|
||||
const newHighlight = !!actions?.tweaks?.highlight;
|
||||
|
||||
if (newHighlight) {
|
||||
// TODO: Handle mentions received while the client is offline
|
||||
// See also https://github.com/vector-im/element-web/issues/9069
|
||||
let newCount = currentHighlightCount;
|
||||
if (newHighlight && !oldHighlight) newCount++;
|
||||
if (!newHighlight && oldHighlight) newCount--;
|
||||
|
||||
const newCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event) + 1;
|
||||
if (isThreadEvent) {
|
||||
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
|
||||
} else {
|
||||
@@ -9843,23 +9771,18 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
|
||||
}
|
||||
}
|
||||
|
||||
// Total count is used to typically increment a room notification counter, but not loudly highlight it.
|
||||
const currentTotalCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event);
|
||||
|
||||
// `notify` is used in practice for incrementing the total count
|
||||
const newNotify = !!actions?.notify;
|
||||
|
||||
// The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore
|
||||
// the server here as it's always going to tell us to increment for encrypted events.
|
||||
if (newNotify) {
|
||||
// Total count is used to typically increment a room notification counter, but not loudly highlight it.
|
||||
const newCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event) + 1;
|
||||
if (isThreadEvent) {
|
||||
room.setThreadUnreadNotificationCount(
|
||||
event.threadRootId,
|
||||
NotificationCountType.Total,
|
||||
currentTotalCount + 1,
|
||||
);
|
||||
room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Total, newCount);
|
||||
} else {
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, newCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { IEventDecryptionResult } from "../@types/crypto";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
/**
|
||||
@@ -88,9 +90,21 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @returns the cross signing information for the user.
|
||||
*/
|
||||
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
|
||||
|
||||
/**
|
||||
* Check the cross signing trust of the current user
|
||||
*
|
||||
* @param opts - Options object.
|
||||
*
|
||||
* @deprecated Unneeded for the new crypto
|
||||
*/
|
||||
checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise<void>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api */
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface SyncCryptoCallbacks {
|
||||
/**
|
||||
* Called by the /sync loop whenever there are incoming to-device messages.
|
||||
@@ -146,6 +160,9 @@ export interface SyncCryptoCallbacks {
|
||||
onSyncCompleted(syncState: OnSyncCompletedData): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface OnSyncCompletedData {
|
||||
/**
|
||||
* The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
|
||||
@@ -157,3 +174,10 @@ export interface OnSyncCompletedData {
|
||||
*/
|
||||
catchingUp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}.
|
||||
*/
|
||||
export interface CheckOwnCrossSigningTrustOpts {
|
||||
allowPrivateKeyRequests?: boolean;
|
||||
}
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ export function getHttpUriForMxc(
|
||||
}
|
||||
}
|
||||
let serverAndMediaId = mxc.slice(6); // strips mxc://
|
||||
let prefix = "/_matrix/media/r0/download/";
|
||||
let prefix = "/_matrix/media/v3/download/";
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (width) {
|
||||
@@ -64,7 +64,7 @@ export function getHttpUriForMxc(
|
||||
if (Object.keys(params).length > 0) {
|
||||
// these are thumbnailing params so they probably want the
|
||||
// thumbnailing API...
|
||||
prefix = "/_matrix/media/r0/thumbnail/";
|
||||
prefix = "/_matrix/media/v3/thumbnail/";
|
||||
}
|
||||
|
||||
const fragmentOffset = serverAndMediaId.indexOf("#");
|
||||
|
||||
+99
-1
@@ -20,7 +20,8 @@ import { DeviceMap } from "./models/device";
|
||||
import { UIAuthCallback } from "./interactive-auth";
|
||||
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
|
||||
import { VerificationRequest } from "./crypto-api/verification";
|
||||
import { KeyBackupInfo } from "./crypto-api/keybackup";
|
||||
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
|
||||
import { ISignatures } from "./@types/signed";
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -136,6 +137,22 @@ export interface CryptoApi {
|
||||
*/
|
||||
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
|
||||
|
||||
/**
|
||||
* Mark the given device as locally verified.
|
||||
*
|
||||
* Marking a devices as locally verified has much the same effect as completing the verification dance, or receiving
|
||||
* a cross-signing signature for it.
|
||||
*
|
||||
* @param userId - owner of the device
|
||||
* @param deviceId - unique identifier for the device.
|
||||
* @param verified - whether to mark the device as verified. Defaults to 'true'.
|
||||
*
|
||||
* @throws an error if the device is unknown, or has not published any encryption keys.
|
||||
*
|
||||
* @remarks Fires {@link CryptoEvent#DeviceVerificationChanged}
|
||||
*/
|
||||
setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Checks whether cross signing:
|
||||
* - is enabled on this account and trusted by this device
|
||||
@@ -250,9 +267,30 @@ export interface CryptoApi {
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
* @deprecated prefer `userId` parameter variant.
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room and user.
|
||||
*
|
||||
* @param roomId - the room to use for verification.
|
||||
* @param userId - search for a verification request for the given user.
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any.
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string, userId?: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Request a key verification from another user, using a DM.
|
||||
*
|
||||
* @param userId - the user to request verification with.
|
||||
* @param roomId - the room to use for verification.
|
||||
*
|
||||
* @returns resolves to a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest>;
|
||||
|
||||
/**
|
||||
* Send a verification request to our other devices.
|
||||
*
|
||||
@@ -284,6 +322,55 @@ export interface CryptoApi {
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Secure key backup
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Fetch the backup decryption key we have saved in our store.
|
||||
*
|
||||
* This can be used for gossiping the key to other devices.
|
||||
*
|
||||
* @returns the key, if any, or null
|
||||
*/
|
||||
getSessionBackupPrivateKey(): Promise<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current status of key backup.
|
||||
*
|
||||
* @returns If automatic key backups are enabled, the `version` of the active backup. Otherwise, `null`.
|
||||
*/
|
||||
getActiveSessionBackupVersion(): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Determine if a key backup can be trusted.
|
||||
*
|
||||
* @param info - key backup info dict from {@link MatrixClient#getKeyBackupVersion}.
|
||||
*/
|
||||
isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo>;
|
||||
|
||||
/**
|
||||
* Force a re-check of the key backup and enable/disable it as appropriate.
|
||||
*
|
||||
* Fetches the current backup information from the server. If there is a backup, and it is trusted, starts
|
||||
* backing up to it; otherwise, disables backups.
|
||||
*
|
||||
* @returns `null` if there is no backup on the server. Otherwise, data on the backup as returned by the server,
|
||||
* and trust information (as returned by {@link isKeyBackupTrusted}).
|
||||
*/
|
||||
checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,6 +566,17 @@ export enum CrossSigningKey {
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
/**
|
||||
* Information on one of the cross-signing keys.
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3keysdevice_signingupload
|
||||
*/
|
||||
export interface CrossSigningKeyInfo {
|
||||
keys: { [algorithm: string]: string };
|
||||
signatures?: ISignatures;
|
||||
usage: string[];
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,10 @@ export interface Aes256AuthData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra info of a recovery key
|
||||
* Information about a server-side key backup.
|
||||
*
|
||||
* Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion)
|
||||
* and hence {@link MatrixClient#getKeyBackupVersion}.
|
||||
*/
|
||||
export interface KeyBackupInfo {
|
||||
algorithm: string;
|
||||
@@ -40,3 +43,28 @@ export interface KeyBackupInfo {
|
||||
etag?: string;
|
||||
version?: string; // number contained within
|
||||
}
|
||||
|
||||
/**
|
||||
* Information on whether a given server-side backup is trusted.
|
||||
*/
|
||||
export interface BackupTrustInfo {
|
||||
/**
|
||||
* Is this backup trusted?
|
||||
*
|
||||
* True if, and only if, there is a valid signature on the backup from a trusted device.
|
||||
*/
|
||||
readonly trusted: boolean;
|
||||
|
||||
/**
|
||||
* True if this backup matches the stored decryption key.
|
||||
*/
|
||||
readonly matchesDecryptionKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
export interface KeyBackupCheck {
|
||||
backupInfo: KeyBackupInfo;
|
||||
trustInfo: BackupTrustInfo;
|
||||
}
|
||||
|
||||
@@ -136,12 +136,27 @@ export interface VerificationRequest
|
||||
/**
|
||||
* Send an `m.key.verification.start` event to start verification via a particular method.
|
||||
*
|
||||
* This is normally used when starting a verification via emojis (ie, `method` is set to `m.sas.v1`).
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*/
|
||||
startVerification(method: string): Promise<Verifier>;
|
||||
|
||||
/**
|
||||
* Start a QR code verification by providing a scanned QR code for this verification flow.
|
||||
*
|
||||
* Validates the QR code, and if it is ok, sends an `m.key.verification.start` event with `method` set to
|
||||
* `m.reciprocate.v1`, to tell the other side the scan was successful.
|
||||
*
|
||||
* See also {@link VerificationRequest#startVerification} which can be used to start other verification methods.
|
||||
*
|
||||
* @param qrCodeData - the decoded QR code.
|
||||
* @returns A verifier; call `.verify()` on it to wait for the other side to complete the verification flow.
|
||||
*/
|
||||
scanQRCode(qrCodeData: Uint8Array): Promise<Verifier>;
|
||||
|
||||
/**
|
||||
* The verifier which is doing the actual verification, once the method has been established.
|
||||
* Only defined when the `phase` is Started.
|
||||
@@ -152,9 +167,19 @@ export interface VerificationRequest
|
||||
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
|
||||
*
|
||||
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
|
||||
*/
|
||||
getQRCodeBytes(): Buffer | undefined;
|
||||
|
||||
/**
|
||||
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only returns data once `phase` is {@link VerificationPhase.Ready} and the other party can scan a QR code;
|
||||
* otherwise returns `undefined`.
|
||||
*/
|
||||
generateQRCode(): Promise<Buffer | undefined>;
|
||||
|
||||
/**
|
||||
* If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling
|
||||
* this verification.
|
||||
@@ -285,7 +310,7 @@ export enum VerifierEvent {
|
||||
ShowSas = "show_sas",
|
||||
|
||||
/**
|
||||
* QR code data should be displayed to the user.
|
||||
* The user should confirm if the other side has scanned our QR code.
|
||||
*
|
||||
* The payload is the {@link ShowQrCodeCallbacks} object.
|
||||
*/
|
||||
@@ -300,7 +325,7 @@ export type VerifierEventHandlerMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Callbacks for user actions while a QR code is displayed.
|
||||
* Callbacks for user actions to confirm that the other side has scanned our QR code.
|
||||
*
|
||||
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
|
||||
* verifier as `reciprocateQREvent`.
|
||||
|
||||
@@ -18,8 +18,7 @@ limitations under the License.
|
||||
* Cross signing methods
|
||||
*/
|
||||
|
||||
import { PkSigning } from "@matrix-org/olm";
|
||||
|
||||
import type { PkSigning } from "@matrix-org/olm";
|
||||
import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
|
||||
import { logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
@@ -245,7 +244,7 @@ export class CrossSigningInfo {
|
||||
* @returns A map from key type (string) to private key (Uint8Array)
|
||||
*/
|
||||
public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> {
|
||||
const keys = new Map();
|
||||
const keys = new Map<string, Uint8Array>();
|
||||
const cacheCallbacks = this.cacheCallbacks;
|
||||
if (!cacheCallbacks) return keys;
|
||||
for (const type of ["master", "self_signing", "user_signing"]) {
|
||||
@@ -294,8 +293,8 @@ export class CrossSigningInfo {
|
||||
|
||||
const privateKeys: Record<string, Uint8Array> = {};
|
||||
const keys: Record<string, ICrossSigningKey> = {};
|
||||
let masterSigning;
|
||||
let masterPub;
|
||||
let masterSigning: PkSigning | undefined;
|
||||
let masterPub: string | undefined;
|
||||
|
||||
try {
|
||||
if (level & CrossSigningLevel.MASTER) {
|
||||
|
||||
@@ -188,7 +188,7 @@ export class EncryptionSetupOperation {
|
||||
// We must only call `uploadDeviceSigningKeys` from inside this auth
|
||||
// helper to ensure we properly handle auth errors.
|
||||
await this.crossSigningKeys.authUpload?.((authDict) => {
|
||||
return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys);
|
||||
return baseApis.uploadDeviceSigningKeys(authDict ?? undefined, keys as CrossSigningKeys);
|
||||
});
|
||||
|
||||
// pass the new keys to the main instance of our own CrossSigningInfo.
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export class OlmDevice {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`Storing megolm session ${senderKey}|${sessionId} with first index ` +
|
||||
session.first_known_index(),
|
||||
);
|
||||
|
||||
+33
-11
@@ -40,6 +40,7 @@ import { UnstableValue } from "../NamespacedValue";
|
||||
import { CryptoEvent } from "./index";
|
||||
import { crypto } from "./crypto";
|
||||
import { HTTPError, MatrixError } from "../http-api";
|
||||
import { BackupTrustInfo } from "../crypto-api/keybackup";
|
||||
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
@@ -54,6 +55,7 @@ type SigInfo = {
|
||||
deviceTrust?: DeviceTrustLevel;
|
||||
};
|
||||
|
||||
/** @deprecated Prefer {@link BackupTrustInfo} */
|
||||
export type TrustInfo = {
|
||||
usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
|
||||
sigs: SigInfo[];
|
||||
@@ -438,6 +440,7 @@ export class BackupManager {
|
||||
* @param maxDelay - Maximum delay to wait in ms. 0 means no delay.
|
||||
*/
|
||||
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
|
||||
logger.debug(`Key backup: scheduleKeyBackupSend currentSending:${this.sendingBackups} delay:${maxDelay}`);
|
||||
if (this.sendingBackups) return;
|
||||
|
||||
this.sendingBackups = true;
|
||||
@@ -450,6 +453,7 @@ export class BackupManager {
|
||||
await sleep(delay);
|
||||
if (!this.clientRunning) {
|
||||
logger.debug("Key backup send aborted, client stopped");
|
||||
this.sendingBackups = false;
|
||||
return;
|
||||
}
|
||||
let numFailures = 0; // number of consecutive failures
|
||||
@@ -461,24 +465,26 @@ export class BackupManager {
|
||||
const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
|
||||
if (numBackedUp === 0) {
|
||||
// no sessions left needing backup: we're done
|
||||
this.sendingBackups = false;
|
||||
return;
|
||||
}
|
||||
numFailures = 0;
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
logger.log("Key backup request failed", err);
|
||||
if ((<MatrixError>err).data) {
|
||||
if (
|
||||
(<MatrixError>err).data.errcode == "M_NOT_FOUND" ||
|
||||
(<MatrixError>err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION"
|
||||
) {
|
||||
// Re-check key backup status on error, so we can be
|
||||
// sure to present the current situation when asked.
|
||||
await this.checkKeyBackup();
|
||||
if (err instanceof MatrixError) {
|
||||
const errCode = err.data.errcode;
|
||||
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
||||
// Set to false now as `checkKeyBackup` might schedule a backupsend before this one ends.
|
||||
this.sendingBackups = false;
|
||||
// Backup version has changed or this backup version
|
||||
// has been deleted
|
||||
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, (<MatrixError>err).data.errcode!);
|
||||
throw err;
|
||||
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, errCode);
|
||||
// Re-check key backup status on error, so we can be
|
||||
// sure to present the current situation when asked.
|
||||
// This call might restart the backup loop if new backup version is trusted
|
||||
await this.checkKeyBackup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,10 +495,14 @@ export class BackupManager {
|
||||
|
||||
if (!this.clientRunning) {
|
||||
logger.debug("Key backup send loop aborted, client stopped");
|
||||
this.sendingBackups = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
} catch (err) {
|
||||
// No one actually checks errors on this promise, it's spawned internally.
|
||||
// Just log, apps/client should use events to check status
|
||||
logger.log(`Backup loop failed ${err}`);
|
||||
this.sendingBackups = false;
|
||||
}
|
||||
}
|
||||
@@ -829,3 +839,15 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
|
||||
};
|
||||
|
||||
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
|
||||
|
||||
/**
|
||||
* Map a legacy {@link TrustInfo} into a new-style {@link BackupTrustInfo}.
|
||||
*
|
||||
* @param trustInfo - trustInfo to convert
|
||||
*/
|
||||
export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): BackupTrustInfo {
|
||||
return {
|
||||
trusted: trustInfo.usable,
|
||||
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface IDevice {
|
||||
|
||||
/**
|
||||
* Information about a user's device
|
||||
*
|
||||
* Superceded by {@link Device}.
|
||||
*/
|
||||
export class DeviceInfo {
|
||||
/**
|
||||
|
||||
+62
-13
@@ -48,9 +48,9 @@ import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChan
|
||||
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
|
||||
import { IllegalMethod } from "./verification/IllegalMethod";
|
||||
import { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
||||
import { DehydrationManager } from "./dehydration";
|
||||
import { BackupManager } from "./backup";
|
||||
import { BackupManager, backupTrustInfoFromLegacyTrustInfo } from "./backup";
|
||||
import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
@@ -87,10 +87,13 @@ import {
|
||||
} from "../secret-storage";
|
||||
import { ISecretRequest } from "./SecretSharing";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
@@ -1242,21 +1245,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @returns the key, if any, or null
|
||||
*/
|
||||
public async getSessionBackupPrivateKey(): Promise<Uint8Array | null> {
|
||||
let key = await new Promise<any>((resolve) => {
|
||||
// TODO types
|
||||
const encodedKey = await new Promise<Uint8Array | IEncryptedPayload | string | null>((resolve) => {
|
||||
this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1");
|
||||
});
|
||||
});
|
||||
|
||||
let key: Uint8Array | null = null;
|
||||
|
||||
// make sure we have a Uint8Array, rather than a string
|
||||
if (key && typeof key === "string") {
|
||||
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key));
|
||||
if (typeof encodedKey === "string") {
|
||||
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(encodedKey) || encodedKey));
|
||||
await this.storeSessionBackupPrivateKey(key);
|
||||
}
|
||||
if (key && key.ciphertext) {
|
||||
if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) {
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1");
|
||||
const decrypted = await decryptAES(encodedKey, pickleKey, "m.megolm_backup.v1");
|
||||
key = olmlib.decodeBase64(decrypted);
|
||||
}
|
||||
return key;
|
||||
@@ -1279,6 +1283,42 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of key backup.
|
||||
*
|
||||
* Implementation of {@link CryptoApi.getActiveSessionBackupVersion}.
|
||||
*/
|
||||
public async getActiveSessionBackupVersion(): Promise<string | null> {
|
||||
if (this.backupManager.getKeyBackupEnabled()) {
|
||||
return this.backupManager.version ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a key backup can be trusted.
|
||||
*
|
||||
* Implementation of {@link Crypto.CryptoApi.isKeyBackupTrusted}.
|
||||
*/
|
||||
public async isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo> {
|
||||
const trustInfo = await this.backupManager.isKeyBackupTrusted(info);
|
||||
return backupTrustInfoFromLegacyTrustInfo(trustInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a re-check of the key backup and enable/disable it as appropriate.
|
||||
*
|
||||
* Implementation of {@link CryptoApi.checkKeyBackupAndEnable}.
|
||||
*/
|
||||
public async checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null> {
|
||||
const checkResult = await this.backupManager.checkKeyBackup();
|
||||
if (!checkResult || !checkResult.backupInfo) return null;
|
||||
return {
|
||||
backupInfo: checkResult.backupInfo,
|
||||
trustInfo: backupTrustInfoFromLegacyTrustInfo(checkResult.trustInfo),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that a given cross-signing private key matches a given public key.
|
||||
* This can be used by the getCrossSigningKey callback to verify that the
|
||||
@@ -2173,6 +2213,15 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.deviceList.saveIfDirty(delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given device as locally verified.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#setDeviceVerified}.
|
||||
*/
|
||||
public async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
|
||||
await this.setDeviceVerification(userId, deviceId, verified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the blocked/verified state of the given device
|
||||
*
|
||||
@@ -2202,12 +2251,12 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
blocked: boolean | null = null,
|
||||
known: boolean | null = null,
|
||||
keys?: Record<string, string>,
|
||||
): Promise<DeviceInfo | CrossSigningInfo> {
|
||||
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
|
||||
// Check if the 'device' is actually a cross signing key
|
||||
// The js-sdk's verification treats cross-signing keys as devices
|
||||
// and so uses this method to mark them verified.
|
||||
const xsk = this.deviceList.getStoredCrossSigningForUser(userId);
|
||||
if (xsk && xsk.getId() === deviceId) {
|
||||
if (xsk?.getId() === deviceId) {
|
||||
if (blocked !== null || known !== null) {
|
||||
throw new Error("Cannot set blocked or known for a cross-signing key");
|
||||
}
|
||||
@@ -2257,7 +2306,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// This will emit events when it comes back down the sync
|
||||
// (we could do local echo to speed things up)
|
||||
}
|
||||
return device as any; // TODO types
|
||||
return device!;
|
||||
} else {
|
||||
return xsk;
|
||||
}
|
||||
@@ -2346,8 +2395,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return deviceObj;
|
||||
}
|
||||
|
||||
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
|
||||
return this.inRoomVerificationRequests.findRequestInProgress(roomId);
|
||||
public findVerificationRequestDMInProgress(roomId: string, userId?: string): VerificationRequest | undefined {
|
||||
return this.inRoomVerificationRequests.findRequestInProgress(roomId, userId);
|
||||
}
|
||||
|
||||
public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
|
||||
|
||||
@@ -350,11 +350,11 @@ export class InRoomRequests implements IRequestsMap {
|
||||
}
|
||||
}
|
||||
|
||||
public findRequestInProgress(roomId: string): VerificationRequest | undefined {
|
||||
public findRequestInProgress(roomId: string, userId?: string): VerificationRequest | undefined {
|
||||
const requestsByTxnId = this.requestsByRoomId.get(roomId);
|
||||
if (requestsByTxnId) {
|
||||
for (const request of requestsByTxnId.values()) {
|
||||
if (request.pending) {
|
||||
if (request.pending && (userId === undefined || request.requestingUserId === userId)) {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
|
||||
/** Only set after a .ready if the other party can scan a QR code
|
||||
*
|
||||
* @deprecated Prefer `getQRCodeBytes`.
|
||||
* @deprecated Prefer `generateQRCode`.
|
||||
*/
|
||||
public get qrCodeData(): QRCodeData | null {
|
||||
return this._qrCodeData;
|
||||
@@ -278,11 +278,23 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
|
||||
*
|
||||
* @deprecated Prefer `generateQRCode`.
|
||||
*/
|
||||
public getQRCodeBytes(): Buffer | undefined {
|
||||
return this._qrCodeData?.getBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only returns data once `phase` is `Ready` and the other party can scan a QR code;
|
||||
* otherwise returns `undefined`.
|
||||
*/
|
||||
public async generateQRCode(): Promise<Buffer | undefined> {
|
||||
return this.getQRCodeBytes();
|
||||
}
|
||||
|
||||
/** Checks whether the other party supports a given verification method.
|
||||
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
|
||||
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
|
||||
@@ -466,6 +478,10 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
return verifier;
|
||||
}
|
||||
|
||||
public scanQRCode(qrCodeData: Uint8Array): Promise<Verifier> {
|
||||
throw new Error("QR code scanning not supported by legacy crypto");
|
||||
}
|
||||
|
||||
/**
|
||||
* sends the initial .request event.
|
||||
* @returns resolves when the event has been sent.
|
||||
|
||||
+3
-1
@@ -97,7 +97,7 @@ export interface ICapabilities {
|
||||
*/
|
||||
export class RoomWidgetClient extends MatrixClient {
|
||||
private room?: Room;
|
||||
private widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
|
||||
private readonly widgetApiReady: Promise<void>;
|
||||
private lifecycle?: AbortController;
|
||||
private syncState: SyncState | null = null;
|
||||
|
||||
@@ -109,6 +109,8 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
) {
|
||||
super(opts);
|
||||
|
||||
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
|
||||
|
||||
// Request capabilities for the functionality this client needs to support
|
||||
if (
|
||||
capabilities.sendEvent?.length ||
|
||||
|
||||
@@ -63,6 +63,7 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
},
|
||||
[Feature.IntentionalMentions]: {
|
||||
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
|
||||
matrixVersion: "v1.7",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+33
-4
@@ -22,11 +22,10 @@ import { checkObjectHasKeys, encodeParams } from "../utils";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { Method } from "./method";
|
||||
import { ConnectionError, MatrixError } from "./errors";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts, Body } from "./interface";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
|
||||
import { QueryDict } from "../utils";
|
||||
|
||||
type Body = Record<string, any> | BodyInit;
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
@@ -223,8 +222,11 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
method: Method,
|
||||
url: URL | string,
|
||||
body?: Body,
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {},
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
const urlForLogs = this.sanitizeUrlForLogs(url);
|
||||
logger.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
|
||||
|
||||
const headers = Object.assign({}, opts.headers || {});
|
||||
const json = opts.json ?? true;
|
||||
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
|
||||
@@ -260,6 +262,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
const { signal, cleanup } = anySignal(signals);
|
||||
|
||||
let res: Response;
|
||||
const start = Date.now();
|
||||
try {
|
||||
res = await this.fetch(url, {
|
||||
signal,
|
||||
@@ -273,8 +276,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
cache: "no-cache",
|
||||
credentials: "omit", // we send credentials via headers
|
||||
keepalive: keepAlive,
|
||||
priority: opts.priority,
|
||||
});
|
||||
|
||||
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
|
||||
} catch (e) {
|
||||
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
|
||||
if ((<Error>e).name === "AbortError") {
|
||||
throw e;
|
||||
}
|
||||
@@ -293,6 +300,28 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
return res as ResponseType<T, O>;
|
||||
}
|
||||
|
||||
private sanitizeUrlForLogs(url: URL | string): string {
|
||||
try {
|
||||
let asUrl: URL;
|
||||
if (typeof url === "string") {
|
||||
asUrl = new URL(url);
|
||||
} else {
|
||||
asUrl = url;
|
||||
}
|
||||
// Remove the values of any URL params that could contain potential secrets
|
||||
const sanitizedQs = new URLSearchParams();
|
||||
for (const key of asUrl.searchParams.keys()) {
|
||||
sanitizedQs.append(key, "xxx");
|
||||
}
|
||||
const sanitizedQsString = sanitizedQs.toString();
|
||||
const sanitizedQsUrlPiece = sanitizedQsString ? `?${sanitizedQsString}` : "";
|
||||
|
||||
return asUrl.origin + asUrl.pathname + sanitizedQsUrlPiece;
|
||||
} catch (error) {
|
||||
// defensive coding for malformed url
|
||||
return "??";
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Form and return a homeserver request URL based on the given path params and prefix.
|
||||
* @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
|
||||
|
||||
@@ -110,7 +110,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
});
|
||||
};
|
||||
|
||||
const url = this.getUrl("/upload", undefined, MediaPrefix.R0);
|
||||
const url = this.getUrl("/upload", undefined, MediaPrefix.V3);
|
||||
|
||||
if (includeFilename && fileName) {
|
||||
url.searchParams.set("filename", encodeURIComponent(fileName));
|
||||
@@ -139,7 +139,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
const headers: Record<string, string> = { "Content-Type": contentType };
|
||||
|
||||
this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, {
|
||||
prefix: MediaPrefix.R0,
|
||||
prefix: MediaPrefix.V3,
|
||||
headers,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
@@ -182,7 +182,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
|
||||
public getContentUri(): IContentUri {
|
||||
return {
|
||||
base: this.opts.baseUrl,
|
||||
path: MediaPrefix.R0 + "/upload",
|
||||
path: MediaPrefix.V3 + "/upload",
|
||||
params: {
|
||||
access_token: this.opts.accessToken!,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ limitations under the License.
|
||||
|
||||
import { MatrixError } from "./errors";
|
||||
|
||||
export type Body = Record<string, any> | BodyInit;
|
||||
|
||||
export interface IHttpOpts {
|
||||
fetchFn?: typeof global.fetch;
|
||||
|
||||
@@ -31,7 +33,7 @@ export interface IHttpOpts {
|
||||
localTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IRequestOpts {
|
||||
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
/**
|
||||
* The alternative base url to use.
|
||||
* If not specified, uses this.opts.baseUrl
|
||||
|
||||
@@ -16,11 +16,7 @@ limitations under the License.
|
||||
|
||||
export enum ClientPrefix {
|
||||
/**
|
||||
* A constant representing the URI path for release 0 of the Client-Server HTTP API.
|
||||
*/
|
||||
R0 = "/_matrix/client/r0",
|
||||
/**
|
||||
* A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API.
|
||||
* A constant representing the URI path for Client-Server API endpoints versioned at v1.
|
||||
*/
|
||||
V1 = "/_matrix/client/v1",
|
||||
/**
|
||||
@@ -42,7 +38,11 @@ export enum IdentityPrefix {
|
||||
|
||||
export enum MediaPrefix {
|
||||
/**
|
||||
* URI path for the media repo API
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
|
||||
*/
|
||||
R0 = "/_matrix/media/r0",
|
||||
V1 = "/_matrix/media/v3",
|
||||
/**
|
||||
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
|
||||
*/
|
||||
V3 = "/_matrix/media/v3",
|
||||
}
|
||||
|
||||
+16
-9
@@ -159,11 +159,12 @@ export class NoAuthFlowFoundError extends Error {
|
||||
* The type of an application callback to perform the user-interactive bit of UIA.
|
||||
*
|
||||
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
|
||||
* makes the HTTP request.
|
||||
* makes the HTTP request. The `authData` parameter in `makeRequest` can be set to null to omit the `auth` field
|
||||
* from the UIA request.
|
||||
*
|
||||
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
|
||||
*/
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
|
||||
interface IOpts<T> {
|
||||
/**
|
||||
@@ -263,8 +264,8 @@ export class InteractiveAuth<T> {
|
||||
private readonly requestEmailTokenCallback: IOpts<T>["requestEmailToken"];
|
||||
private readonly supportedStages?: Set<string>;
|
||||
|
||||
// The current latest data received from the server during the user interactive auth flow.
|
||||
private data: IAuthData;
|
||||
// The current latest data or error received from the server during the user interactive auth flow.
|
||||
private data: IAuthData & MatrixError["data"];
|
||||
private emailSid?: string;
|
||||
private requestingEmailToken = false;
|
||||
private attemptAuthDeferred: IDeferred<T> | null = null;
|
||||
@@ -344,10 +345,8 @@ export class InteractiveAuth<T> {
|
||||
sid: this.emailSid,
|
||||
client_secret: this.clientSecret,
|
||||
};
|
||||
if (await this.matrixClient.doesServerRequireIdServerParam()) {
|
||||
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
}
|
||||
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
|
||||
creds.id_server = idServerParsedUrl.host;
|
||||
authDict = {
|
||||
type: EMAIL_STAGE_TYPE,
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
@@ -548,7 +547,7 @@ export class InteractiveAuth<T> {
|
||||
matrixError.data.session = (this.data as IAuthData).session;
|
||||
}
|
||||
if (matrixError) {
|
||||
this.data = matrixError.data as IAuthData;
|
||||
this.data = matrixError.data;
|
||||
}
|
||||
try {
|
||||
this.startNextAuthStage();
|
||||
@@ -601,6 +600,14 @@ export class InteractiveAuth<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.data?.errcode || this.data?.error) {
|
||||
this.stateUpdatedCallback(nextStage, {
|
||||
errcode: this.data?.errcode || "",
|
||||
error: this.data?.error || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE ? { emailSid: this.emailSid } : {});
|
||||
}
|
||||
|
||||
|
||||
+26
-3
@@ -41,6 +41,7 @@ export * from "./models/thread";
|
||||
export * from "./models/typed-event-emitter";
|
||||
export * from "./models/user";
|
||||
export * from "./models/device";
|
||||
export * from "./models/search-result";
|
||||
export * from "./scheduler";
|
||||
export * from "./filter";
|
||||
export * from "./timeline-window";
|
||||
@@ -49,23 +50,45 @@ export * from "./service-types";
|
||||
export * from "./store/memory";
|
||||
export * from "./store/indexeddb";
|
||||
export * from "./crypto/store/memory-crypto-store";
|
||||
export * from "./crypto/store/localStorage-crypto-store";
|
||||
export * from "./crypto/store/indexeddb-crypto-store";
|
||||
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
|
||||
export * from "./content-repo";
|
||||
export * from "./@types/uia";
|
||||
export * from "./@types/event";
|
||||
export * from "./@types/PushRules";
|
||||
export * from "./@types/partials";
|
||||
export * from "./@types/requests";
|
||||
export * from "./@types/search";
|
||||
export * from "./models/room-summary";
|
||||
export * from "./models/event-status";
|
||||
export * as ContentHelpers from "./content-helpers";
|
||||
export * as SecretStorage from "./secret-storage";
|
||||
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
|
||||
export { createNewMatrixCall } from "./webrtc/call";
|
||||
export { createNewMatrixCall, CallEvent } from "./webrtc/call";
|
||||
export type { MatrixCall } from "./webrtc/call";
|
||||
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
|
||||
export type { GroupCall } from "./webrtc/groupCall";
|
||||
export {
|
||||
GroupCall,
|
||||
GroupCallEvent,
|
||||
GroupCallIntent,
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
GroupCallStatsReportEvent,
|
||||
} from "./webrtc/groupCall";
|
||||
export { CryptoEvent } from "./crypto";
|
||||
export { SyncState, SetPresence } from "./sync";
|
||||
export type { ISyncStateData as SyncStateData } from "./sync";
|
||||
export { SlidingSyncEvent } from "./sliding-sync";
|
||||
export { MediaHandlerEvent } from "./webrtc/mediaHandler";
|
||||
export { CallFeedEvent } from "./webrtc/callFeed";
|
||||
export { StatsReport } from "./webrtc/stats/statsReport";
|
||||
export { Relations, RelationsEvent } from "./models/relations";
|
||||
export { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
export { LocalStorageErrors } from "./store/local-storage-events-emitter";
|
||||
export { IdentityProviderBrand, SSOAction } from "./@types/auth";
|
||||
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth";
|
||||
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces";
|
||||
export { LocationAssetType } from "./@types/location";
|
||||
|
||||
/**
|
||||
* Types supporting cryptography.
|
||||
|
||||
@@ -984,11 +984,20 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
);
|
||||
}
|
||||
|
||||
const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event);
|
||||
const { threadId, shouldLiveInRoom, shouldLiveInThread } = this.room.eventShouldLiveIn(event);
|
||||
|
||||
if (this.thread) {
|
||||
return this.thread.id === threadId;
|
||||
}
|
||||
|
||||
if (!shouldLiveInRoom && !shouldLiveInThread) {
|
||||
logger.warn(
|
||||
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${
|
||||
this.room?.roomId
|
||||
} eventId=${event.getId()} threadId=${event.threadRootId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return shouldLiveInRoom;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export interface IContent {
|
||||
"displayname"?: string;
|
||||
"m.relates_to"?: IEventRelation;
|
||||
|
||||
"org.matrix.msc3952.mentions"?: IMentions;
|
||||
"m.mentions"?: IMentions;
|
||||
}
|
||||
|
||||
type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>;
|
||||
|
||||
+21
-19
@@ -57,9 +57,10 @@ export abstract class ReadReceipt<
|
||||
// which pass in an event ID and get back some receipts, so we also store
|
||||
// a pre-cached list for this purpose.
|
||||
// Map: receipt type → user Id → receipt
|
||||
private receipts = new MapWithDefault<string, Map<string, [WrappedReceipt | null, WrappedReceipt | null]>>(
|
||||
() => new Map(),
|
||||
);
|
||||
private receipts = new MapWithDefault<
|
||||
string,
|
||||
Map<string, [realReceipt: WrappedReceipt | null, syntheticReceipt: WrappedReceipt | null]>
|
||||
>(() => new Map());
|
||||
private receiptCacheByEventId: ReceiptCache = new Map();
|
||||
|
||||
public abstract getUnfilteredTimelineSet(): EventTimelineSet;
|
||||
@@ -85,6 +86,13 @@ export abstract class ReadReceipt<
|
||||
return syntheticReceipt ?? realReceipt;
|
||||
}
|
||||
|
||||
private compareReceipts(a: WrappedReceipt, b: WrappedReceipt): number {
|
||||
// Try compare them in our unfiltered timeline set order, falling back to receipt timestamp which should be
|
||||
// relatively sane as receipts are set only by the originating homeserver so as long as its clock doesn't
|
||||
// jump around then it should be valid.
|
||||
return this.getUnfilteredTimelineSet().compareEventOrdering(a.eventId, b.eventId) ?? a.data.ts - b.data.ts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -99,19 +107,13 @@ export abstract class ReadReceipt<
|
||||
// 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;
|
||||
comparison = this.compareReceipts(publicReadReceipt, privateReadReceipt);
|
||||
}
|
||||
|
||||
// The public receipt is more likely to drift out of date so the private
|
||||
@@ -142,20 +144,20 @@ export abstract class ReadReceipt<
|
||||
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,
|
||||
};
|
||||
|
||||
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.compareReceipts(existingReceipt, wrappedReceipt);
|
||||
if (ordering >= 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
|
||||
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
|
||||
|
||||
|
||||
+37
-23
@@ -1877,6 +1877,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Takes the given thread root events and creates threads for them.
|
||||
*/
|
||||
public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
if (!this.client.supportsThreads()) return;
|
||||
for (const rootEvent of events) {
|
||||
EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline);
|
||||
if (!this.getThread(rootEvent.getId()!)) {
|
||||
@@ -2031,6 +2032,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @internal
|
||||
*/
|
||||
private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
|
||||
if (!this.client.supportsThreads()) return;
|
||||
if (this.threadsTimelineSets.length === 0) return;
|
||||
|
||||
const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
|
||||
@@ -2093,6 +2095,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which timeline(s) a given event should live in
|
||||
* Thread roots live in both the main timeline and their corresponding thread timeline
|
||||
* Relations, redactions, replies to thread relation events live only in the thread timeline
|
||||
* Relations (other than m.thread), redactions, replies to a thread root live only in the main timeline
|
||||
* Relations, redactions, replies where the parent cannot be found live in no timelines but should be aggregated regardless.
|
||||
* Otherwise, the event lives in the main timeline only.
|
||||
*/
|
||||
public eventShouldLiveIn(
|
||||
event: MatrixEvent,
|
||||
events?: MatrixEvent[],
|
||||
@@ -2109,7 +2119,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
// A thread root is always shown in both timelines
|
||||
// A thread root is the only event shown in both timelines
|
||||
if (event.isThreadRoot || roots?.has(event.getId()!)) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
@@ -2118,8 +2128,29 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
// A thread relation (1st and 2nd order) is always only shown in a thread
|
||||
const isThreadRelation = event.isRelation(THREAD_RELATION_TYPE.name);
|
||||
const parentEventId = event.getAssociatedId();
|
||||
const threadRootId = event.threadRootId;
|
||||
|
||||
// Where the parent is the thread root and this is a non-thread relation this should live only in the main timeline
|
||||
if (!!parentEventId && !isThreadRelation && (threadRootId === parentEventId || roots?.has(parentEventId!))) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
|
||||
let parentEvent: MatrixEvent | undefined;
|
||||
if (parentEventId) {
|
||||
parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId);
|
||||
}
|
||||
|
||||
// Treat non-thread-relations, redactions, and replies as extensions of their parents so evaluate parentEvent instead
|
||||
if (parentEvent && !isThreadRelation) {
|
||||
return this.eventShouldLiveIn(parentEvent, events, roots);
|
||||
}
|
||||
|
||||
// A thread relation (1st and 2nd order) is always only shown in a thread
|
||||
if (threadRootId != undefined) {
|
||||
return {
|
||||
shouldLiveInRoom: false,
|
||||
@@ -2128,33 +2159,16 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
const parentEventId = event.getAssociatedId();
|
||||
let parentEvent: MatrixEvent | undefined;
|
||||
if (parentEventId) {
|
||||
parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId);
|
||||
}
|
||||
|
||||
// Treat relations and redactions as extensions of their parents so evaluate parentEvent instead
|
||||
if (parentEvent && (event.isRelation() || event.isRedaction())) {
|
||||
return this.eventShouldLiveIn(parentEvent, events, roots);
|
||||
}
|
||||
|
||||
if (!event.isRelation()) {
|
||||
// Due to replies not being typical relations and being used as fallbacks for threads relations
|
||||
// If we bypass the if case above then we know we are not a thread, so if we are still a reply
|
||||
// then we know that we must be in the main timeline. Same goes if we have no associated parent event.
|
||||
if (!parentEventId || !!event.replyEventId) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Edge case where we know the event is a relation but don't have the parentEvent
|
||||
if (roots?.has(event.relationEventId!)) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: true,
|
||||
threadId: event.relationEventId,
|
||||
};
|
||||
}
|
||||
|
||||
// We've exhausted all scenarios,
|
||||
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
|
||||
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
|
||||
|
||||
@@ -326,11 +326,6 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
|
||||
this.setEventMetadata(event);
|
||||
|
||||
if (!this.initialEventsFetched && !toStartOfTimeline && event.getId() === this.id) {
|
||||
// We're loading the thread organically
|
||||
this.initialEventsFetched = true;
|
||||
}
|
||||
|
||||
const lastReply = this.lastReply();
|
||||
const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
|
||||
|
||||
|
||||
@@ -217,6 +217,10 @@ export class TypedEventEmitter<
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public removeAllListeners(event?: Events | EventEmitterEvents): this {
|
||||
// EventEmitter::removeAllListeners uses `arguments.length` to determine undefined case
|
||||
if (event === undefined) {
|
||||
return super.removeAllListeners();
|
||||
}
|
||||
return super.removeAllListeners(event);
|
||||
}
|
||||
|
||||
|
||||
+129
-79
@@ -14,13 +14,24 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { Method } from "../http-api";
|
||||
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
|
||||
import { logger } from "../logger";
|
||||
import { randomString } from "../randomstring";
|
||||
import { OidcError } from "./error";
|
||||
import { validateIdToken, ValidatedIssuerConfig } from "./validate";
|
||||
import {
|
||||
validateIdToken,
|
||||
ValidatedIssuerMetadata,
|
||||
validateStoredUserState,
|
||||
UserState,
|
||||
BearerTokenResponse,
|
||||
validateBearerTokenResponse,
|
||||
} from "./validate";
|
||||
|
||||
// reexport for backwards compatibility
|
||||
export type { BearerTokenResponse };
|
||||
|
||||
/**
|
||||
* Authorization parameters which are used in the authentication request of an OIDC auth code flow.
|
||||
@@ -35,6 +46,11 @@ export type AuthorizationParams = {
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Generate the scope used in authorization request with OIDC OP
|
||||
* @returns scope
|
||||
*/
|
||||
const generateScope = (): string => {
|
||||
const deviceId = randomString(10);
|
||||
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||
@@ -74,6 +90,7 @@ export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: stri
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated use generateOidcAuthorizationUrl
|
||||
* Generate a URL to attempt authorization with the OP
|
||||
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
||||
* @param authorizationUrl - endpoint to attempt authorization with the OP
|
||||
@@ -102,36 +119,48 @@ export const generateAuthorizationUrl = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* The expected response type from the token endpoint during authorization code flow
|
||||
* Normalized to always use capitalized 'Bearer' for token_type
|
||||
*
|
||||
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
|
||||
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
||||
* @experimental
|
||||
* Generate a URL to attempt authorization with the OP
|
||||
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
||||
* @param oidcClientSettings - oidc configuration
|
||||
* @param homeserverName - used as state
|
||||
* @returns a Promise with the url as a string
|
||||
*/
|
||||
export type BearerTokenResponse = {
|
||||
token_type: "Bearer";
|
||||
access_token: string;
|
||||
scope: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
};
|
||||
export const generateOidcAuthorizationUrl = async ({
|
||||
metadata,
|
||||
redirectUri,
|
||||
clientId,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
nonce,
|
||||
}: {
|
||||
clientId: string;
|
||||
metadata: ValidatedIssuerMetadata;
|
||||
homeserverUrl: string;
|
||||
identityServerUrl?: string;
|
||||
redirectUri: string;
|
||||
nonce: string;
|
||||
}): Promise<string> => {
|
||||
const scope = await generateScope();
|
||||
const oidcClient = new OidcClient({
|
||||
...metadata,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: metadata.issuer,
|
||||
response_mode: "query",
|
||||
response_type: "code",
|
||||
scope,
|
||||
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
|
||||
});
|
||||
const userState: UserState = { homeserverUrl, nonce, identityServerUrl };
|
||||
const request = await oidcClient.createSigninRequest({
|
||||
state: userState,
|
||||
nonce,
|
||||
});
|
||||
|
||||
/**
|
||||
* Expected response type from the token endpoint during authorization code flow
|
||||
* as it comes over the wire.
|
||||
* Should be normalized to use capital case 'Bearer' for token_type property
|
||||
*
|
||||
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
|
||||
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
||||
*/
|
||||
type WireBearerTokenResponse = BearerTokenResponse & {
|
||||
token_type: "Bearer" | "bearer";
|
||||
return request.url;
|
||||
};
|
||||
|
||||
const isResponseObject = (response: unknown): response is Record<string, unknown> =>
|
||||
!!response && typeof response === "object";
|
||||
|
||||
/**
|
||||
* Normalize token_type to use capital case to make consuming the token response easier
|
||||
* token_type is case insensitive, and it is spec-compliant for OPs to return token_type: "bearer"
|
||||
@@ -141,21 +170,18 @@ const isResponseObject = (response: unknown): response is Record<string, unknown
|
||||
* @param response - validated token response
|
||||
* @returns response with token_type set to 'Bearer'
|
||||
*/
|
||||
const normalizeBearerTokenResponseTokenType = (response: WireBearerTokenResponse): BearerTokenResponse => ({
|
||||
...response,
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const isValidBearerTokenResponse = (response: unknown): response is WireBearerTokenResponse =>
|
||||
isResponseObject(response) &&
|
||||
typeof response["token_type"] === "string" &&
|
||||
// token_type is case insensitive, some OPs return `token_type: "bearer"`
|
||||
response["token_type"].toLowerCase() === "bearer" &&
|
||||
typeof response["access_token"] === "string" &&
|
||||
(!("refresh_token" in response) || typeof response["refresh_token"] === "string") &&
|
||||
(!("expires_in" in response) || typeof response["expires_in"] === "number");
|
||||
const normalizeBearerTokenResponseTokenType = (response: SigninResponse): BearerTokenResponse =>
|
||||
({
|
||||
id_token: response.id_token,
|
||||
scope: response.scope,
|
||||
expires_at: response.expires_at,
|
||||
refresh_token: response.refresh_token,
|
||||
access_token: response.access_token,
|
||||
token_type: "Bearer",
|
||||
} as BearerTokenResponse);
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Attempt to exchange authorization code for bearer token.
|
||||
*
|
||||
* Takes the authorization code returned by the OpenID Provider via the authorization URL, and makes a
|
||||
@@ -168,47 +194,71 @@ const isValidBearerTokenResponse = (response: unknown): response is WireBearerTo
|
||||
*/
|
||||
export const completeAuthorizationCodeGrant = async (
|
||||
code: string,
|
||||
{
|
||||
clientId,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
delegatedAuthConfig,
|
||||
nonce,
|
||||
}: {
|
||||
clientId: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig;
|
||||
nonce: string;
|
||||
},
|
||||
): Promise<BearerTokenResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "authorization_code");
|
||||
params.append("client_id", clientId);
|
||||
params.append("code_verifier", codeVerifier);
|
||||
params.append("redirect_uri", redirectUri);
|
||||
params.append("code", code);
|
||||
const metadata = params.toString();
|
||||
state: string,
|
||||
): Promise<{
|
||||
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
|
||||
tokenResponse: BearerTokenResponse;
|
||||
homeserverUrl: string;
|
||||
identityServerUrl?: string;
|
||||
}> => {
|
||||
/**
|
||||
* Element Web strips and changes the url on starting the app
|
||||
* Use the code and state from query params to rebuild a url
|
||||
* so that oidc-client can parse it
|
||||
*/
|
||||
const reconstructedUrl = new URL(window.location.origin);
|
||||
reconstructedUrl.searchParams.append("code", code);
|
||||
reconstructedUrl.searchParams.append("state", state);
|
||||
|
||||
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
||||
// set oidc-client to use our logger
|
||||
Log.setLogger(logger);
|
||||
try {
|
||||
const response = new SigninResponse(reconstructedUrl.searchParams);
|
||||
|
||||
const response = await fetch(delegatedAuthConfig.tokenEndpoint, {
|
||||
method: Method.Post,
|
||||
headers,
|
||||
body: metadata,
|
||||
});
|
||||
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
|
||||
|
||||
if (response.status >= 400) {
|
||||
// retrieve the state we put in storage at the start of oidc auth flow
|
||||
const stateString = await stateStore.get(response.state!);
|
||||
if (!stateString) {
|
||||
throw new Error(OidcError.MissingOrInvalidStoredState);
|
||||
}
|
||||
|
||||
// hydrate the sign in state and create a client
|
||||
// the stored sign in state includes oidc configuration we set at the start of the oidc login flow
|
||||
const signInState = SigninState.fromStorageString(stateString);
|
||||
const client = new OidcClient({ ...signInState, stateStore });
|
||||
|
||||
// validate the code and state, and attempt to swap the code for tokens
|
||||
const signinResponse = await client.processSigninResponse(reconstructedUrl.href);
|
||||
|
||||
// extra values we stored at the start of the login flow
|
||||
// used to complete login in the client
|
||||
const userState = signinResponse.userState;
|
||||
validateStoredUserState(userState);
|
||||
|
||||
// throws when response is invalid
|
||||
validateBearerTokenResponse(signinResponse);
|
||||
// throws when token is invalid
|
||||
validateIdToken(signinResponse.id_token, client.settings.authority, client.settings.client_id, userState.nonce);
|
||||
const normalizedTokenResponse = normalizeBearerTokenResponseTokenType(signinResponse);
|
||||
|
||||
return {
|
||||
oidcClientSettings: {
|
||||
clientId: client.settings.client_id,
|
||||
issuer: client.settings.authority,
|
||||
},
|
||||
tokenResponse: normalizedTokenResponse,
|
||||
homeserverUrl: userState.homeserverUrl,
|
||||
identityServerUrl: userState.identityServerUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Oidc login failed", error);
|
||||
const errorType = (error as Error).message;
|
||||
|
||||
// rethrow errors that we recognise
|
||||
if (Object.values(OidcError).includes(errorType as any)) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(OidcError.CodeExchangeFailed);
|
||||
}
|
||||
|
||||
const token = await response.json();
|
||||
|
||||
if (isValidBearerTokenResponse(token)) {
|
||||
// throws when token is invalid
|
||||
validateIdToken(token.id_token, delegatedAuthConfig.issuer, clientId, nonce);
|
||||
return normalizeBearerTokenResponseTokenType(token);
|
||||
}
|
||||
|
||||
throw new Error(OidcError.InvalidBearerTokenResponse);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MetadataService, OidcClientSettingsStore, SigningKey } from "oidc-client-ts";
|
||||
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { isValidatedIssuerMetadata, ValidatedIssuerMetadata, validateWellKnownAuthentication } from "./validate";
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Discover and validate delegated auth configuration
|
||||
* - m.authentication config is present and valid
|
||||
* - delegated auth issuer openid-configuration is reachable
|
||||
* - delegated auth issuer openid-configuration is configured correctly for us
|
||||
* When successful, validated metadata is returned
|
||||
* @param wellKnown - configuration object as returned
|
||||
* by the .well-known auto-discovery endpoint
|
||||
* @returns validated authentication metadata and optionally signing keys
|
||||
* @throws when delegated auth config is invalid or unreachable
|
||||
*/
|
||||
export const discoverAndValidateAuthenticationConfig = async (
|
||||
authenticationConfig?: IDelegatedAuthConfig,
|
||||
): Promise<
|
||||
IDelegatedAuthConfig & {
|
||||
metadata: ValidatedIssuerMetadata;
|
||||
signingKeys?: SigningKey[];
|
||||
}
|
||||
> => {
|
||||
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authenticationConfig);
|
||||
|
||||
// create a temporary settings store so we can use metadata service for discovery
|
||||
const settings = new OidcClientSettingsStore({
|
||||
authority: homeserverAuthenticationConfig.issuer,
|
||||
redirect_uri: "", // Not known yet, this is here to make the type checker happy
|
||||
client_id: "", // Not known yet, this is here to make the type checker happy
|
||||
});
|
||||
const metadataService = new MetadataService(settings);
|
||||
const metadata = await metadataService.getMetadata();
|
||||
const signingKeys = (await metadataService.getSigningKeys()) ?? undefined;
|
||||
|
||||
isValidatedIssuerMetadata(metadata);
|
||||
|
||||
return {
|
||||
...homeserverAuthenticationConfig,
|
||||
metadata,
|
||||
signingKeys,
|
||||
};
|
||||
};
|
||||
@@ -25,4 +25,5 @@ export enum OidcError {
|
||||
CodeExchangeFailed = "Failed to exchange code for token",
|
||||
InvalidBearerTokenResponse = "Invalid bearer token response",
|
||||
InvalidIdToken = "Invalid ID token",
|
||||
MissingOrInvalidStoredState = "State required to finish logging in is not found in storage.",
|
||||
}
|
||||
|
||||
+114
-7
@@ -15,8 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import jwtDecode from "jwt-decode";
|
||||
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
|
||||
|
||||
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client";
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import { OidcError } from "./error";
|
||||
|
||||
@@ -39,9 +40,7 @@ export type ValidatedIssuerConfig = {
|
||||
* @returns config - when present and valid
|
||||
* @throws when config is not found or invalid
|
||||
*/
|
||||
export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): IDelegatedAuthConfig => {
|
||||
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown);
|
||||
|
||||
export const validateWellKnownAuthentication = (authentication?: IDelegatedAuthConfig): IDelegatedAuthConfig => {
|
||||
if (!authentication) {
|
||||
throw new Error(OidcError.NotSupported);
|
||||
}
|
||||
@@ -63,14 +62,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
|
||||
if (!wellKnown[key] || !optionalStringProperty(wellKnown, key)) {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid`);
|
||||
logger.error(`Missing or invalid property: ${key}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
|
||||
if (!!wellKnown[key] && typeof wellKnown[key] !== "string") {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid`);
|
||||
logger.error(`Invalid property: ${key}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -78,7 +77,7 @@ const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string)
|
||||
const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, value: any): boolean => {
|
||||
const array = wellKnown[key];
|
||||
if (!array || !Array.isArray(array) || !array.includes(value)) {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid. ${value} is required.`);
|
||||
logger.error(`Invalid property: ${key}. ${value} is required.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -101,6 +100,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
||||
const isInvalid = [
|
||||
requiredStringProperty(wellKnown, "authorization_endpoint"),
|
||||
requiredStringProperty(wellKnown, "token_endpoint"),
|
||||
requiredStringProperty(wellKnown, "revocation_endpoint"),
|
||||
optionalStringProperty(wellKnown, "registration_endpoint"),
|
||||
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
||||
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
||||
@@ -119,6 +119,36 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
||||
throw new Error(OidcError.OpSupport);
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata from OIDC authority discovery
|
||||
* With validated properties required in type
|
||||
*/
|
||||
export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
|
||||
Pick<
|
||||
OidcMetadata,
|
||||
| "issuer"
|
||||
| "authorization_endpoint"
|
||||
| "token_endpoint"
|
||||
| "registration_endpoint"
|
||||
| "revocation_endpoint"
|
||||
| "response_types_supported"
|
||||
| "grant_types_supported"
|
||||
| "code_challenge_methods_supported"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Wraps validateOIDCIssuerWellKnown in a type assertion
|
||||
* that asserts expected properties are present
|
||||
* (Typescript assertions cannot be arrow functions)
|
||||
* @param metadata - issuer openid-configuration response
|
||||
* @throws when metadata validation fails
|
||||
*/
|
||||
export function isValidatedIssuerMetadata(
|
||||
metadata: Partial<OidcMetadata>,
|
||||
): asserts metadata is ValidatedIssuerMetadata {
|
||||
validateOIDCIssuerWellKnown(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JWT claims.
|
||||
*
|
||||
@@ -199,3 +229,80 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
|
||||
throw new Error(OidcError.InvalidIdToken);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* State we ask OidcClient to store when starting oidc authorization flow (in `generateOidcAuthorizationUrl`)
|
||||
* so that we can access it on return from the OP and complete login
|
||||
*/
|
||||
export type UserState = {
|
||||
/**
|
||||
* Remember which server we were trying to login to
|
||||
*/
|
||||
homeserverUrl: string;
|
||||
identityServerUrl?: string;
|
||||
/**
|
||||
* Used to validate id token
|
||||
*/
|
||||
nonce: string;
|
||||
};
|
||||
/**
|
||||
* Validate stored user state exists and is valid
|
||||
* @param userState - userState returned by oidcClient.processSigninResponse
|
||||
* @throws when userState is invalid
|
||||
*/
|
||||
export function validateStoredUserState(userState: unknown): asserts userState is UserState {
|
||||
if (!isRecord(userState)) {
|
||||
logger.error("Stored user state not found");
|
||||
throw new Error(OidcError.MissingOrInvalidStoredState);
|
||||
}
|
||||
const isInvalid = [
|
||||
requiredStringProperty(userState, "homeserverUrl"),
|
||||
requiredStringProperty(userState, "nonce"),
|
||||
optionalStringProperty(userState, "identityServerUrl"),
|
||||
].some((isValid) => !isValid);
|
||||
|
||||
if (isInvalid) {
|
||||
throw new Error(OidcError.MissingOrInvalidStoredState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected response type from the token endpoint during authorization code flow
|
||||
* Normalized to always use capitalized 'Bearer' for token_type
|
||||
*
|
||||
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
|
||||
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
||||
*/
|
||||
export type BearerTokenResponse = {
|
||||
token_type: "Bearer";
|
||||
access_token: string;
|
||||
scope: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
// from oidc-client-ts
|
||||
expires_at?: number;
|
||||
id_token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make required properties required in type
|
||||
*/
|
||||
type ValidSignInResponse = SigninResponse &
|
||||
BearerTokenResponse & {
|
||||
token_type: "Bearer" | "bearer";
|
||||
};
|
||||
|
||||
const isValidBearerTokenResponse = (response: unknown): response is ValidSignInResponse =>
|
||||
isRecord(response) &&
|
||||
requiredStringProperty(response, "token_type") &&
|
||||
// token_type is case insensitive, some OPs return `token_type: "bearer"`
|
||||
(response["token_type"] as string).toLowerCase() === "bearer" &&
|
||||
requiredStringProperty(response, "access_token") &&
|
||||
requiredStringProperty(response, "refresh_token") &&
|
||||
(!("expires_in" in response) || typeof response["expires_in"] === "number");
|
||||
|
||||
export function validateBearerTokenResponse(response: unknown): asserts response is ValidSignInResponse {
|
||||
if (!isValidBearerTokenResponse(response)) {
|
||||
throw new Error(OidcError.InvalidBearerTokenResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyContains,
|
||||
key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids",
|
||||
key: "content.m\\.mentions.user_ids",
|
||||
value: "", // The user ID is dynamically added in rewriteDefaultRules.
|
||||
},
|
||||
],
|
||||
@@ -91,7 +91,7 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyIs,
|
||||
key: "content.org\\.matrix\\.msc3952\\.mentions.room",
|
||||
key: "content.m\\.mentions.room",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
@@ -724,7 +724,7 @@ export class PushProcessor {
|
||||
// Disable the deprecated mentions push rules if the new mentions property exists.
|
||||
if (
|
||||
this.client.supportsIntentionalMentions() &&
|
||||
ev.getContent()["org.matrix.msc3952.mentions"] !== undefined &&
|
||||
ev.getContent()["m.mentions"] !== undefined &&
|
||||
(rule.rule_id === RuleId.ContainsUserName ||
|
||||
rule.rule_id === RuleId.ContainsDisplayName ||
|
||||
rule.rule_id === RuleId.AtRoomNotification)
|
||||
|
||||
@@ -17,7 +17,12 @@ limitations under the License.
|
||||
import { UnstableValue } from "matrix-events-sdk";
|
||||
|
||||
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
|
||||
import { IMSC3882GetLoginTokenCapability, MatrixClient, UNSTABLE_MSC3882_CAPABILITY } from "../client";
|
||||
import {
|
||||
ICrossSigningKey,
|
||||
IMSC3882GetLoginTokenCapability,
|
||||
MatrixClient,
|
||||
UNSTABLE_MSC3882_CAPABILITY,
|
||||
} from "../client";
|
||||
import { CrossSigningInfo } from "../crypto/CrossSigning";
|
||||
import { DeviceInfo } from "../crypto/deviceinfo";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
||||
@@ -178,7 +183,9 @@ export class MSC3906Rendezvous {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise<CrossSigningInfo | DeviceInfo> {
|
||||
private async verifyAndCrossSignDevice(
|
||||
deviceInfo: DeviceInfo,
|
||||
): Promise<CrossSigningInfo | DeviceInfo | ICrossSigningKey | undefined> {
|
||||
if (!this.client.crypto) {
|
||||
throw new Error("Crypto not available on client");
|
||||
}
|
||||
@@ -223,7 +230,7 @@ export class MSC3906Rendezvous {
|
||||
*/
|
||||
public async verifyNewDeviceOnExistingDevice(
|
||||
timeout = 10 * 1000,
|
||||
): Promise<DeviceInfo | CrossSigningInfo | undefined> {
|
||||
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
|
||||
if (!this.newDeviceId) {
|
||||
throw new Error("No new device to sign");
|
||||
}
|
||||
|
||||
@@ -14,19 +14,26 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
import { logger } from "../logger";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { UIAuthCallback } from "../interactive-auth";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
|
||||
/** Manages the cross-signing keys for our own user.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class CrossSigningIdentity {
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
/** Called if the cross signing keys are imported from the secret storage */
|
||||
private readonly onCrossSigningKeysImport: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,7 +46,15 @@ export class CrossSigningIdentity {
|
||||
}
|
||||
|
||||
const olmDeviceStatus: CrossSigningStatus = await this.olmMachine.crossSigningStatus();
|
||||
const privateKeysInSecretStorage = false; // TODO
|
||||
|
||||
// Try to fetch cross signing keys from the secret storage
|
||||
const masterKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.master");
|
||||
const selfSigningKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.self_signing");
|
||||
const userSigningKeyFromSecretStorage = await this.secretStorage.get("m.cross_signing.user_signing");
|
||||
const privateKeysInSecretStorage = Boolean(
|
||||
masterKeyFromSecretStorage && selfSigningKeyFromSecretStorage && userSigningKeyFromSecretStorage,
|
||||
);
|
||||
|
||||
const olmDeviceHasKeys =
|
||||
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
|
||||
|
||||
@@ -65,7 +80,23 @@ export class CrossSigningIdentity {
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"in secret storage, reading storage and caching locally",
|
||||
);
|
||||
throw new Error("TODO");
|
||||
await this.olmMachine.importCrossSigningKeys(
|
||||
masterKeyFromSecretStorage,
|
||||
selfSigningKeyFromSecretStorage,
|
||||
userSigningKeyFromSecretStorage,
|
||||
);
|
||||
|
||||
// Get the current device
|
||||
const device: RustSdkCryptoJs.Device = await this.olmMachine.getDevice(
|
||||
this.olmMachine.userId,
|
||||
this.olmMachine.deviceId,
|
||||
);
|
||||
|
||||
// Sign the device with our cross-signing key and upload the signature
|
||||
const request: RustSdkCryptoJs.SignatureUploadRequest = await device.verify();
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
|
||||
this.onCrossSigningKeysImport();
|
||||
}
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
|
||||
@@ -22,6 +22,8 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
* KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
|
||||
*
|
||||
* We have one of these per `RustCrypto` (and hence per `MatrixClient`).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class KeyClaimManager {
|
||||
private currentClaimPromise: Promise<void>;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
SigningKeysUploadRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-js";
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
@@ -34,6 +34,8 @@ import { UIAResponse } from "../@types/uia";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface OutgoingRequest {
|
||||
readonly id: string | undefined;
|
||||
@@ -49,6 +51,8 @@ export interface OutgoingRequest {
|
||||
* * holding the reference to the `MatrixHttpApi`
|
||||
* * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them
|
||||
* * sending the results of such requests back to the rust backend.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class OutgoingRequestProcessor {
|
||||
public constructor(
|
||||
@@ -71,7 +75,12 @@ export class OutgoingRequestProcessor {
|
||||
} else if (msg instanceof SignatureUploadRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
|
||||
} else if (msg instanceof KeysBackupRequest) {
|
||||
resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
|
||||
resp = await this.rawJsonRequest(
|
||||
Method.Put,
|
||||
"/_matrix/client/v3/room_keys/keys",
|
||||
{ version: msg.version },
|
||||
msg.body,
|
||||
);
|
||||
} else if (msg instanceof ToDeviceRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
|
||||
@@ -79,7 +88,7 @@ export class OutgoingRequestProcessor {
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else if (msg instanceof RoomMessageRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(msg.room_id)}/send/` +
|
||||
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else if (msg instanceof SigningKeysUploadRequest) {
|
||||
@@ -112,11 +121,13 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
|
||||
const parsedBody = JSON.parse(body);
|
||||
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
|
||||
const newBody = {
|
||||
const makeRequest = async (auth: IAuthDict | null): Promise<UIAResponse<T>> => {
|
||||
const newBody: Record<string, any> = {
|
||||
...parsedBody,
|
||||
auth,
|
||||
};
|
||||
if (auth !== null) {
|
||||
newBody.auth = auth;
|
||||
}
|
||||
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
|
||||
return JSON.parse(resp) as T;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { EventType } from "../@types/event";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
@@ -26,6 +26,8 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
|
||||
/**
|
||||
* RoomEncryptor: responsible for encrypting messages to a given room
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class RoomEncryptor {
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OlmMachine, SignatureVerification } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { BackupTrustInfo, Curve25519AuthData, KeyBackupCheck, KeyBackupInfo } from "../crypto-api/keybackup";
|
||||
import { logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { CryptoEvent } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { sleep } from "../utils";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap> {
|
||||
/** Have we checked if there is a backup on the server which we can use */
|
||||
private checkedForBackup = false;
|
||||
private activeBackupVersion: string | null = null;
|
||||
private stopped = false;
|
||||
|
||||
/** whether {@link backupKeysLoop} is currently running */
|
||||
private backupKeysLoopRunning = false;
|
||||
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the RustBackupManager to stop.
|
||||
* The RustBackupManager is scheduling background uploads of keys to the backup, this
|
||||
* call allows to cancel the process when the client is stoppped.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backup version we are currently backing up to, if any
|
||||
*/
|
||||
public async getActiveBackupVersion(): Promise<string | null> {
|
||||
if (!this.olmMachine.isBackupEnabled()) return null;
|
||||
return this.activeBackupVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a key backup can be trusted.
|
||||
*
|
||||
* @param info - key backup info dict from {@link MatrixClient#getKeyBackupVersion}.
|
||||
*/
|
||||
public async isKeyBackupTrusted(info: KeyBackupInfo): Promise<BackupTrustInfo> {
|
||||
const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info);
|
||||
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
const pubKeyForSavedPrivateKey = backupKeys?.decryptionKey?.megolmV1PublicKey;
|
||||
const backupMatchesSavedPrivateKey =
|
||||
info.algorithm === pubKeyForSavedPrivateKey?.algorithm &&
|
||||
(info.auth_data as Curve25519AuthData)?.public_key === pubKeyForSavedPrivateKey.publicKeyBase64;
|
||||
|
||||
return {
|
||||
matchesDecryptionKey: backupMatchesSavedPrivateKey,
|
||||
trusted: signatureVerification.trusted(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the key backup and enable/disable it as appropriate.
|
||||
*
|
||||
* @param force - whether we should force a re-check even if one has already happened.
|
||||
*/
|
||||
public checkKeyBackupAndEnable(force: boolean): Promise<KeyBackupCheck | null> {
|
||||
if (!force && this.checkedForBackup) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
// make sure there is only one check going on at a time
|
||||
if (!this.keyBackupCheckInProgress) {
|
||||
this.keyBackupCheckInProgress = this.doCheckKeyBackup().finally(() => {
|
||||
this.keyBackupCheckInProgress = null;
|
||||
});
|
||||
}
|
||||
return this.keyBackupCheckInProgress;
|
||||
}
|
||||
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
|
||||
|
||||
/** Helper for `checkKeyBackup` */
|
||||
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
|
||||
logger.log("Checking key backup status...");
|
||||
let backupInfo: KeyBackupInfo | null = null;
|
||||
try {
|
||||
backupInfo = await this.requestKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.warn("Error checking for active key backup", e);
|
||||
return null;
|
||||
}
|
||||
this.checkedForBackup = true;
|
||||
|
||||
if (backupInfo && !backupInfo.version) {
|
||||
logger.warn("active backup lacks a useful 'version'; ignoring it");
|
||||
}
|
||||
|
||||
const activeVersion = await this.getActiveBackupVersion();
|
||||
|
||||
if (!backupInfo) {
|
||||
if (activeVersion !== null) {
|
||||
logger.log("No key backup present on server: disabling key backup");
|
||||
await this.disableKeyBackup();
|
||||
} else {
|
||||
logger.log("No key backup present on server: not enabling key backup");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
|
||||
|
||||
if (!trustInfo.trusted) {
|
||||
if (activeVersion !== null) {
|
||||
logger.log("Key backup present on server but not trusted: disabling key backup");
|
||||
await this.disableKeyBackup();
|
||||
} else {
|
||||
logger.log("Key backup present on server but not trusted: not enabling key backup");
|
||||
}
|
||||
} else {
|
||||
if (activeVersion === null) {
|
||||
logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`);
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
} else if (activeVersion !== backupInfo.version) {
|
||||
logger.log(`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`);
|
||||
// This will remove any pending backup request, remove the backup key and reset the backup state of each room key we have.
|
||||
await this.disableKeyBackup();
|
||||
// Enabling will now trigger re-upload of all the keys
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
} else {
|
||||
logger.log(`Backup version ${backupInfo.version} still current`);
|
||||
}
|
||||
}
|
||||
return { backupInfo, trustInfo };
|
||||
}
|
||||
|
||||
private async enableKeyBackup(backupInfo: KeyBackupInfo): Promise<void> {
|
||||
// we know for certain it must be a Curve25519 key, because we have verified it and only Curve25519
|
||||
// keys can be verified.
|
||||
//
|
||||
// we also checked it has a valid `version`.
|
||||
await this.olmMachine.enableBackupV1(
|
||||
(backupInfo.auth_data as Curve25519AuthData).public_key,
|
||||
backupInfo.version!,
|
||||
);
|
||||
this.activeBackupVersion = backupInfo.version!;
|
||||
|
||||
this.emit(CryptoEvent.KeyBackupStatus, true);
|
||||
|
||||
this.backupKeysLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the backup key loop if there is an active trusted backup.
|
||||
* Doesn't try to check the backup server side. To be called when a new
|
||||
* megolm key is known locally.
|
||||
*/
|
||||
public async maybeUploadKey(): Promise<void> {
|
||||
if (this.activeBackupVersion != null) {
|
||||
this.backupKeysLoop();
|
||||
}
|
||||
}
|
||||
|
||||
private async disableKeyBackup(): Promise<void> {
|
||||
await this.olmMachine.disableBackup();
|
||||
this.activeBackupVersion = null;
|
||||
this.emit(CryptoEvent.KeyBackupStatus, false);
|
||||
}
|
||||
|
||||
private async backupKeysLoop(maxDelay = 10000): Promise<void> {
|
||||
if (this.backupKeysLoopRunning) {
|
||||
logger.log(`Backup loop already running`);
|
||||
return;
|
||||
}
|
||||
this.backupKeysLoopRunning = true;
|
||||
|
||||
logger.log(`Starting loop for ${this.activeBackupVersion}.`);
|
||||
|
||||
// wait between 0 and `maxDelay` seconds, to avoid backup
|
||||
// requests from different clients hitting the server all at
|
||||
// the same time when a new key is sent
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay);
|
||||
|
||||
try {
|
||||
let numFailures = 0; // number of consecutive network failures for exponential backoff
|
||||
|
||||
while (!this.stopped) {
|
||||
// Get a batch of room keys to upload
|
||||
const request: RustSdkCryptoJs.KeysBackupRequest | null = await this.olmMachine.backupRoomKeys();
|
||||
|
||||
if (!request || this.stopped || !this.activeBackupVersion) {
|
||||
logger.log(`Ending loop for ${this.activeBackupVersion}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
numFailures = 0;
|
||||
|
||||
const keyCount: RustSdkCryptoJs.RoomKeyCounts = await this.olmMachine.roomKeyCounts();
|
||||
const remaining = keyCount.total - keyCount.backedUp;
|
||||
this.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
logger.error("Error processing backup request for rust crypto-sdk", err);
|
||||
if (err instanceof MatrixError) {
|
||||
const errCode = err.data.errcode;
|
||||
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
||||
await this.disableKeyBackup();
|
||||
this.emit(CryptoEvent.KeyBackupFailed, err.data.errcode!);
|
||||
// There was an active backup and we are out of sync with the server
|
||||
// force a check server side
|
||||
this.backupKeysLoopRunning = false;
|
||||
this.checkKeyBackupAndEnable(true);
|
||||
return;
|
||||
} else if (errCode == "M_LIMIT_EXCEEDED") {
|
||||
// wait for that and then continue?
|
||||
const waitTime = err.data.retry_after_ms;
|
||||
if (waitTime > 0) {
|
||||
sleep(waitTime);
|
||||
continue;
|
||||
} // else go to the normal backoff
|
||||
}
|
||||
}
|
||||
|
||||
// Some other errors (mx, network, or CORS or invalid urls?) anyhow backoff
|
||||
// exponential backoff if we have failures
|
||||
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.backupKeysLoopRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current key backup from the server
|
||||
*
|
||||
* @returns Information object from API or null if there is no active backup.
|
||||
*/
|
||||
private async requestKeyBackupVersion(): Promise<KeyBackupInfo | null> {
|
||||
try {
|
||||
return await this.http.authedRequest<KeyBackupInfo>(
|
||||
Method.Get,
|
||||
"/room_keys/version",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: ClientPrefix.V3,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RustBackupCryptoEvents =
|
||||
| CryptoEvent.KeyBackupStatus
|
||||
| CryptoEvent.KeyBackupSessionsRemaining
|
||||
| CryptoEvent.KeyBackupFailed;
|
||||
|
||||
export type RustBackupCryptoEventMap = {
|
||||
[CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void;
|
||||
[CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void;
|
||||
[CryptoEvent.KeyBackupFailed]: (errCode: string) => void;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user