Compare commits
84 Commits
v26.1.0
...
v27.0.0-rc.2
| Author | SHA1 | Date | |
|---|---|---|---|
| d45a0b894a | |||
| 102739e0fb | |||
| 0d7e4a0fa5 | |||
| d4628e78d4 | |||
| dcf71e0c8f | |||
| 77267e393c | |||
| 1fdc0af5b7 | |||
| d2b782a2f5 | |||
| 5df4ebaada | |||
| e68a1471c1 | |||
| e42dd74426 | |||
| 2751e191d3 | |||
| b5b86bf1b5 | |||
| 4990bf5ca0 | |||
| b8fa030d5d | |||
| b606d1e54b | |||
| cd7c519dc4 | |||
| 30dd28960c | |||
| 5b635df08d | |||
| 592c497902 | |||
| 8e3f2f3262 | |||
| 5751df1288 | |||
| 40a71101e2 | |||
| 3f095caf2d | |||
| 12a94bdd94 | |||
| 1c1ac137d3 | |||
| 89cabc4912 | |||
| 5be4548b3d | |||
| 09de76bd43 | |||
| 3a694f4998 | |||
| 3a8a1389f5 | |||
| c271e1533a | |||
| 722debe8f9 | |||
| 5165899e82 | |||
| 1828826661 | |||
| 24cee68fa2 | |||
| e645af1fc5 | |||
| de64779c27 | |||
| acbcb4658a | |||
| 815484b543 | |||
| 5a3d1a2a67 | |||
| 18626169e4 | |||
| e4a9f958a0 | |||
| ff29de743c | |||
| 5a68861418 | |||
| e285932776 | |||
| 2af0706b16 | |||
| 4382d2a425 | |||
| 9de4a057df | |||
| b703d4a2cc | |||
| d1dec4cd08 | |||
| 326a13bcfe | |||
| e8fb47fdca | |||
| bd66e3859d | |||
| 96e484a3fe | |||
| 3e646bdfa0 | |||
| 48c4127035 | |||
| f16a6bc654 | |||
| f884c78579 | |||
| 3c59476cf7 | |||
| c8f6c4dd0d | |||
| e8c89e9977 | |||
| df78d7cf67 | |||
| 80fec814a2 | |||
| 8b9672ba43 | |||
| ca00094e67 | |||
| 9c6d5a6c55 | |||
| b77fe465f7 | |||
| 8d93f49443 | |||
| 49f11578f7 | |||
| 8df4be0939 | |||
| 80cdbe1058 | |||
| 9c62d15447 | |||
| afc70528cc | |||
| f938d10f7b | |||
| 22f0b781ea | |||
| 1bae10c4b2 | |||
| 9b5b533c6f | |||
| c425945353 | |||
| 0545f6df09 | |||
| d14fc426e6 | |||
| 9f30defcd1 | |||
| 9b175a4985 | |||
| 826ea5bc58 |
+1
-1
@@ -74,7 +74,7 @@ module.exports = {
|
||||
"jest/no-standalone-expect": [
|
||||
"error",
|
||||
{
|
||||
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
|
||||
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@develop
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.75.0
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@develop
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.75.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -24,18 +24,16 @@ jobs:
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
|
||||
uses: JS-DevTools/npm-publish@a25b4180b728b0279fca97d4e5bccf391685aead # v2.2.0
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
tag: next
|
||||
ignore-scripts: false
|
||||
|
||||
- name: 🎖️ Add `latest` dist-tag to final releases
|
||||
if: github.event.release.prerelease == false
|
||||
run: |
|
||||
package=$(cat package.json | jq -er .name)
|
||||
npm dist-tag add "$package@$release" latest
|
||||
if: github.event.release.prerelease == false && steps.npm-publish.outputs.id
|
||||
run: npm dist-tag add "$release" latest
|
||||
env:
|
||||
# JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc
|
||||
INPUT_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
release: ${{ steps.npm-publish.outputs.version }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
release: ${{ steps.npm-publish.outputs.id }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [16, 18, latest]
|
||||
node: [18, latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
@@ -50,6 +50,9 @@ jobs:
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
|
||||
# tell jest to use coloured output
|
||||
FORCE_COLOR: true
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
Changes in [27.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.0.0-rc.2) (2023-07-14)
|
||||
============================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
|
||||
|
||||
Changes in [27.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.0.0-rc.1) (2023-07-11)
|
||||
============================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* 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)).
|
||||
* Drop support for Node 16 ([\#3533](https://github.com/matrix-org/matrix-js-sdk/pull/3533)).
|
||||
* 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 `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)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)).
|
||||
* ElementR: Add `CryptoApi#bootstrapSecretStorage` ([\#3483](https://github.com/matrix-org/matrix-js-sdk/pull/3483)). Contributed by @florianduros.
|
||||
* Deprecate `MatrixClient.findVerificationRequestDMInProgress`, `MatrixClient.getVerificationRequestsToDeviceInProgress`, and `MatrixClient.requestVerification`, in favour of methods in `CryptoApi`. ([\#3474](https://github.com/matrix-org/matrix-js-sdk/pull/3474)).
|
||||
* Introduce a new `Crypto.VerificationRequest` interface, and deprecate direct access to the old `VerificationRequest` class. Also deprecate some related classes that were exported from `src/crypto/verification/request/VerificationRequest` ([\#3449](https://github.com/matrix-org/matrix-js-sdk/pull/3449)).
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: navigate to authorization endpoint ([\#3499](https://github.com/matrix-org/matrix-js-sdk/pull/3499)). Contributed by @kerryarchibald.
|
||||
* Support for interactive device verification in Element-R. ([\#3505](https://github.com/matrix-org/matrix-js-sdk/pull/3505)).
|
||||
* Support for interactive device verification in Element-R. ([\#3508](https://github.com/matrix-org/matrix-js-sdk/pull/3508)).
|
||||
* Support for interactive device verification in Element-R. ([\#3490](https://github.com/matrix-org/matrix-js-sdk/pull/3490)). Fixes vector-im/element-web#25316.
|
||||
* Element-R: Store cross signing keys in secret storage ([\#3498](https://github.com/matrix-org/matrix-js-sdk/pull/3498)). Contributed by @florianduros.
|
||||
* OIDC: add dynamic client registration util function ([\#3481](https://github.com/matrix-org/matrix-js-sdk/pull/3481)). Contributed by @kerryarchibald.
|
||||
* Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room ([\#3493](https://github.com/matrix-org/matrix-js-sdk/pull/3493)).
|
||||
* ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation ([\#3472](https://github.com/matrix-org/matrix-js-sdk/pull/3472)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Aggregate relations regardless of whether event fits into the timeline ([\#3496](https://github.com/matrix-org/matrix-js-sdk/pull/3496)). Fixes vector-im/element-web#25596.
|
||||
* Fix bug where switching media caused media in subsequent calls to fail ([\#3489](https://github.com/matrix-org/matrix-js-sdk/pull/3489)).
|
||||
* Fix: remove polls from room state on redaction ([\#3475](https://github.com/matrix-org/matrix-js-sdk/pull/3475)). Fixes vector-im/element-web#25573. Contributed by @kerryarchibald.
|
||||
* Fix export type `GeneratedSecretStorageKey` ([\#3479](https://github.com/matrix-org/matrix-js-sdk/pull/3479)). Contributed by @florianduros.
|
||||
* Close IDB database before deleting it to prevent spurious unexpected close errors ([\#3478](https://github.com/matrix-org/matrix-js-sdk/pull/3478)). Fixes vector-im/element-web#25597.
|
||||
|
||||
Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ endpoints from before Matrix 1.1, for example.
|
||||
|
||||
## In a browser
|
||||
|
||||
### Note, the browserify build has been deprecated. Please use a bundler like webpack or vite instead.
|
||||
|
||||
Download the browser version from
|
||||
https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a
|
||||
`<script>` to your page. There will be a global variable `matrixcs`
|
||||
|
||||
+7
-5
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "26.1.0",
|
||||
"version": "27.0.0-rc.2",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn build",
|
||||
@@ -55,13 +55,15 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.10",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.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",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
@@ -101,13 +103,13 @@
|
||||
"debug": "^4.3.4",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.41.0",
|
||||
"eslint": "8.44.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^45.0.0",
|
||||
"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",
|
||||
|
||||
@@ -19,7 +19,8 @@ import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { createClient, MatrixClient, IAuthDict, UIAuthCallback } from "../../../src";
|
||||
import { createClient, IAuthDict, MatrixClient } from "../../../src";
|
||||
import { mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -62,45 +63,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return `{}` for `GET _matrix/client/r0/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)
|
||||
*/
|
||||
function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cross-signing keys, publish the keys
|
||||
* Mock and bootstrap all the required steps
|
||||
* Create cross-signing keys and publish the keys
|
||||
*
|
||||
* @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> {
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
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";
|
||||
|
||||
@@ -402,6 +404,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
// 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;
|
||||
const newBackendOnly = backend !== "rust-sdk" ? test.skip : test;
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
@@ -528,32 +531,58 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
/**
|
||||
* Create the {@link CryptoCallbacks}
|
||||
*/
|
||||
function createCryptoCallbacks(): CryptoCallbacks {
|
||||
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
|
||||
let cachedKey: { keyId: string; key: Uint8Array };
|
||||
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
|
||||
cachedKey = {
|
||||
keyId,
|
||||
key,
|
||||
};
|
||||
};
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: "@alice:localhost",
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "xzcvb",
|
||||
});
|
||||
const getSecretStorageKey = () => Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]);
|
||||
|
||||
/* set up listeners for /keys/upload and /sync */
|
||||
keyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
return {
|
||||
cacheSecretStorageKey,
|
||||
getSecretStorageKey,
|
||||
};
|
||||
}
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
beforeEach(
|
||||
async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
testSenderKey = testE2eKeys.curve25519;
|
||||
});
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: "@alice:localhost",
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "xzcvb",
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
});
|
||||
|
||||
/* set up listeners for /keys/upload and /sync */
|
||||
keyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
testSenderKey = testE2eKeys.curve25519;
|
||||
},
|
||||
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
|
||||
10000,
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
@@ -2169,4 +2198,217 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapSecretStorage", () => {
|
||||
/**
|
||||
* Create a fake secret storage key
|
||||
* Async because `bootstrapSecretStorage` expect an async method
|
||||
*/
|
||||
const createSecretStorageKey = jest.fn().mockResolvedValue({
|
||||
keyInfo: {}, // Returning undefined here used to cause a crash
|
||||
privateKey: Uint8Array.of(32, 33),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/r0/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
|
||||
*/
|
||||
function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// This url is called multiple times during the secret storage bootstrap process
|
||||
// When we received the newly generated key, we return it
|
||||
fetchMock.put(
|
||||
"express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)",
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
|
||||
if (content.key) {
|
||||
resolve(content.key);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Resolved when the cross signing key is uploaded
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content.encrypted);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send in the sync response the provided `secretStorageKey` into the account_data field
|
||||
* The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync
|
||||
* @param secretStorageKey
|
||||
*/
|
||||
function sendSyncResponse(secretStorageKey: string) {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.secret_storage.default_key",
|
||||
content: {
|
||||
key: secretStorageKey,
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
// Needed for secretStorage.getKey or secretStorage.hasKey
|
||||
{
|
||||
type: `m.secret_storage.key.${secretStorageKey}`,
|
||||
content: {
|
||||
key: secretStorageKey,
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
createSecretStorageKey.mockClear();
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"should throw an error if we are unable to create a key because createSecretStorageKey is not set",
|
||||
async () => {
|
||||
await expect(
|
||||
aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }),
|
||||
).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set");
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly("should create a new key", async () => {
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
|
||||
// Check that the uploaded key in stored in the secret storage
|
||||
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
|
||||
// Check that the uploaded key is the default key
|
||||
expect(defaultKeyId).toBe(secretStorageKey);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set",
|
||||
async () => {
|
||||
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Call again bootstrapSecretStorage
|
||||
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
|
||||
// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage",
|
||||
async () => {
|
||||
let bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Call again bootstrapSecretStorage
|
||||
bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly("should upload cross signing keys", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// Before setting up secret-storage, bootstrap cross-signing, so that the client has cross-signing keys.
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({});
|
||||
|
||||
// Now, when we bootstrap secret-storage, the cross-signing keys should be uploaded.
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for the cross signing keys to be uploaded
|
||||
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
|
||||
awaitCrossSigningKeyUpload("master"),
|
||||
awaitCrossSigningKeyUpload("user_signing"),
|
||||
awaitCrossSigningKeyUpload("self_signing"),
|
||||
]);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey`
|
||||
expect(masterKey[secretStorageKey]).toBeDefined();
|
||||
expect(userSigningKey[secretStorageKey]).toBeDefined();
|
||||
expect(selfSigningKey[secretStorageKey]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,27 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { MockResponse } from "fetch-mock";
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import { createClient, MatrixClient } from "../../../src";
|
||||
import { ShowQrCodeCallbacks, ShowSasCallbacks, Verifier, VerifierEvent } from "../../../src/crypto-api/verification";
|
||||
import anotherjson from "another-json";
|
||||
import { MockResponse } from "fetch-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src";
|
||||
import {
|
||||
canAcceptVerificationRequest,
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
VerificationPhase,
|
||||
VerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
@@ -31,50 +45,41 @@ import {
|
||||
TEST_USER_ID,
|
||||
} from "../../test-utils/test-data";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import {
|
||||
Phase,
|
||||
VerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
} from "../../../src/crypto/verification/request/VerificationRequest";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
jest.useFakeTimers();
|
||||
|
||||
let previousCrypto: Crypto | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
// Stub out global.crypto
|
||||
previousCrypto = global["crypto"];
|
||||
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: {
|
||||
getRandomValues: function <T extends Uint8Array>(array: T): T {
|
||||
array.fill(0x12);
|
||||
return array;
|
||||
},
|
||||
},
|
||||
});
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await global.Olm.init();
|
||||
});
|
||||
|
||||
// restore the original global.crypto
|
||||
afterAll(() => {
|
||||
if (previousCrypto === undefined) {
|
||||
// @ts-ignore deleting a non-optional property. It *is* optional really.
|
||||
delete global.crypto;
|
||||
} else {
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: previousCrypto,
|
||||
});
|
||||
}
|
||||
// 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");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
/** 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";
|
||||
|
||||
/**
|
||||
* Integration tests for verification functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
// 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.
|
||||
@@ -83,217 +88,333 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
/** an object which intercepts `/sync` requests on the test homeserver */
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "device_under_test",
|
||||
});
|
||||
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
|
||||
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
mockInitialApiRequests(TEST_HOMESERVER_URL);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
if (aliceClient !== undefined) {
|
||||
await aliceClient.stopClient();
|
||||
}
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
syncResponder = new SyncResponder(aliceClient.getHomeserverUrl());
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
aliceClient.startClient();
|
||||
});
|
||||
|
||||
oldBackendOnly("Outgoing verification: can verify another device via SAS", async () => {
|
||||
// expect requests to download our own keys
|
||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
||||
device_keys: {
|
||||
[TEST_USER_ID]: {
|
||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
let [requestBody, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]),
|
||||
]);
|
||||
const transactionId = request.transactionId;
|
||||
expect(transactionId).toBeDefined();
|
||||
expect(request.phase).toEqual(Phase.Requested);
|
||||
expect(request.roomId).toBeUndefined();
|
||||
// test with (1) the default verification method list, (2) a custom verification method list.
|
||||
const TEST_METHODS = ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
|
||||
it.each([undefined, TEST_METHODS])("can verify via SAS (supported methods=%s)", async (methods) => {
|
||||
aliceClient = await startTestClient({ verificationMethods: methods });
|
||||
await waitForDeviceList();
|
||||
|
||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.methods).toContain("m.sas.v1");
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
// initially there should be no verifications in progress
|
||||
{
|
||||
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
|
||||
expect(requests.length).toEqual(0);
|
||||
}
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready...
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: ["m.sas.v1"],
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
let [requestBody, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
expect(transactionId).toBeDefined();
|
||||
expect(request.phase).toEqual(VerificationPhase.Requested);
|
||||
expect(request.roomId).toBeUndefined();
|
||||
expect(request.isSelfVerification).toBe(true);
|
||||
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(false); // no reply yet
|
||||
expect(request.chosenMethod).toBe(null); // nothing chosen yet
|
||||
expect(request.initiatedByMe).toBe(true);
|
||||
expect(request.otherUserId).toEqual(TEST_USER_ID);
|
||||
expect(request.pending).toBe(true);
|
||||
// we're using fake timers, so the timeout should have exactly 10 minutes left still.
|
||||
expect(request.timeout).toEqual(600_000);
|
||||
|
||||
// and now the request should be visible via `getVerificationRequestsToDeviceInProgress`
|
||||
{
|
||||
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
|
||||
expect(requests.length).toEqual(1);
|
||||
expect(requests[0].transactionId).toEqual(transactionId);
|
||||
}
|
||||
|
||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
if (methods !== undefined) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(new Set(toDeviceMessage.methods)).toEqual(new Set(methods));
|
||||
}
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready...
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID);
|
||||
|
||||
// ... and picks a method with m.key.verification.start
|
||||
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
|
||||
|
||||
// as soon as the Changed event arrives, `verifier` should be defined
|
||||
const verifier = await new Promise<Verifier>((resolve) => {
|
||||
function onChange() {
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true);
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
|
||||
resolve(verifier);
|
||||
}
|
||||
request.once(VerificationRequestEvent.Change, onChange);
|
||||
});
|
||||
|
||||
// start off the verification process: alice will send an `accept`
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept");
|
||||
const verificationPromise = verifier.verify();
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
requestBody = await sendToDevicePromise;
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256");
|
||||
expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]);
|
||||
const macMethod = toDeviceMessage.message_authentication_code;
|
||||
expect(macMethod).toEqual("hkdf-hmac-sha256.v2");
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new global.Olm.SAS();
|
||||
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
requestBody = await expectSendToDeviceMessage("m.key.verification.key");
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
const aliceDevicePubKeyBase64 = toDeviceMessage.key;
|
||||
olmSAS.set_their_key(aliceDevicePubKeyBase64);
|
||||
|
||||
// ... and the client is notified to show the emoji
|
||||
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowSas, resolve);
|
||||
});
|
||||
|
||||
// `getShowSasCallbacks` is an alternative way to get the callbacks
|
||||
expect(verifier.getShowSasCallbacks()).toBe(showSas);
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// user confirms that the emoji match, and alice sends a 'mac'
|
||||
[requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]);
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// the dummy device also confirms that the emoji match, and sends a mac
|
||||
returnToDeviceMessageFromSync(
|
||||
buildSasMacMessage(transactionId, olmSAS, TEST_USER_ID, aliceClient.deviceId!),
|
||||
);
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// the dummy device also confirms done-ness
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
expect(request.pending).toBe(false);
|
||||
|
||||
// at this point, cancelling should do nothing.
|
||||
await request.cancel();
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
|
||||
// we're done with the temporary keypair
|
||||
olmSAS.free();
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Ready);
|
||||
expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID);
|
||||
|
||||
// ... and picks a method with m.key.verification.start
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
it("can initiate SAS verification ourselves", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
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
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true);
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And now Alice starts a SAS verification
|
||||
let sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
await request.startVerification("m.sas.v1");
|
||||
let requestBody = await sendToDevicePromise;
|
||||
|
||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage).toEqual({
|
||||
from_device: aliceClient.deviceId,
|
||||
method: "m.sas.v1",
|
||||
transaction_id: transactionId,
|
||||
hashes: ["sha256"],
|
||||
key_agreement_protocols: ["curve25519"],
|
||||
message_authentication_codes: ["hkdf-hmac-sha256.v2"],
|
||||
short_authentication_string: ["emoji"],
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
// there should now be a verifier
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
|
||||
// start off the verification process: alice will send an `accept`
|
||||
const verificationPromise = verifier.verify();
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
requestBody = await expectSendToDeviceMessage("m.key.verification.accept");
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519");
|
||||
expect(toDeviceMessage.short_authentication_string).toEqual(["emoji"]);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new global.Olm.SAS();
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.key",
|
||||
content: {
|
||||
transaction_id: transactionId,
|
||||
key: olmSAS.get_pubkey(),
|
||||
},
|
||||
});
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
requestBody = await expectSendToDeviceMessage("m.key.verification.key");
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
const aliceDevicePubKeyBase64 = toDeviceMessage.key;
|
||||
olmSAS.set_their_key(aliceDevicePubKeyBase64);
|
||||
|
||||
// ... and the client is notified to show the emoji
|
||||
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowSas, resolve);
|
||||
});
|
||||
|
||||
// `getShowSasCallbacks` is an alternative way to get the callbacks
|
||||
expect(verifier.getShowSasCallbacks()).toBe(showSas);
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// user confirms that the emoji match, and alice sends a 'mac'
|
||||
[requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]);
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// the dummy device also confirms that the emoji match, and sends a mac
|
||||
const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`;
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.mac",
|
||||
content: {
|
||||
keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`),
|
||||
transaction_id: transactionId,
|
||||
mac: {
|
||||
[`ed25519:${TEST_DEVICE_ID}`]: calculateMAC(
|
||||
olmSAS,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
`${macInfoBase}ed25519:${TEST_DEVICE_ID}`,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(Phase.Done);
|
||||
|
||||
// we're done with the temporary keypair
|
||||
olmSAS.free();
|
||||
});
|
||||
|
||||
oldBackendOnly(
|
||||
"Outgoing verification: can verify another device via QR code with an untrusted cross-signing key",
|
||||
async () => {
|
||||
// expect requests to download our own keys
|
||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
||||
device_keys: {
|
||||
[TEST_USER_ID]: {
|
||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
...SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
key_agreement_protocols: expect.arrayContaining(["curve25519-hkdf-sha256"]),
|
||||
message_authentication_codes: expect.arrayContaining(["hkdf-hmac-sha256.v2"]),
|
||||
short_authentication_string: ["decimal", "emoji"],
|
||||
});
|
||||
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
// There should now be a `verifier`
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
const verificationPromise = verifier.verify();
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new global.Olm.SAS();
|
||||
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
|
||||
|
||||
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
|
||||
returnToDeviceMessageFromSync(buildSasAcceptMessage(transactionId, commitmentStr));
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
requestBody = await sendToDevicePromise;
|
||||
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
const aliceDevicePubKeyBase64 = toDeviceMessage.key;
|
||||
olmSAS.set_their_key(aliceDevicePubKeyBase64);
|
||||
|
||||
// ... and the dummy device also sends a 'key'
|
||||
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
|
||||
|
||||
// ... and the client is notified to show the emoji
|
||||
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowSas, resolve);
|
||||
});
|
||||
|
||||
// `getShowSasCallbacks` is an alternative way to get the callbacks
|
||||
expect(verifier.getShowSasCallbacks()).toBe(showSas);
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// user confirms that the emoji match, and alice sends a 'mac'
|
||||
[requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]);
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// the dummy device also confirms that the emoji match, and sends a mac
|
||||
returnToDeviceMessageFromSync(
|
||||
buildSasMacMessage(transactionId, olmSAS, TEST_USER_ID, aliceClient.deviceId!),
|
||||
);
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// the dummy device also confirms done-ness
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
|
||||
// we're done with the temporary keypair
|
||||
olmSAS.free();
|
||||
});
|
||||
|
||||
it("Can make a verification request to *all* devices", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// we need an existing cross-signing key for this
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
const [requestBody, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestOwnUserVerification(),
|
||||
]);
|
||||
|
||||
const transactionId = request.transactionId;
|
||||
expect(transactionId).toBeDefined();
|
||||
expect(request.phase).toEqual(VerificationPhase.Requested);
|
||||
|
||||
// and now the request should be visible via `getVerificationRequestsToDeviceInProgress`
|
||||
{
|
||||
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
|
||||
expect(requests.length).toEqual(1);
|
||||
expect(requests[0].transactionId).toEqual(transactionId);
|
||||
}
|
||||
|
||||
// legacy crypto picks devices individually; rust crypto uses a broadcast message
|
||||
const toDeviceMessage =
|
||||
requestBody.messages[TEST_USER_ID]["*"] ?? requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
});
|
||||
|
||||
oldBackendOnly("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.
|
||||
//
|
||||
// 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);
|
||||
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([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId;
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1");
|
||||
expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1");
|
||||
expect(toDeviceMessage.methods).toContain("m.reciprocate.v1");
|
||||
expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1");
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, with an indication we can scan the QR code
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: ["m.qr_code.scan.v1"],
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
});
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.scan.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Ready);
|
||||
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
|
||||
@@ -320,7 +441,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Started);
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
|
||||
|
||||
// there should now be a verifier
|
||||
@@ -346,9 +467,175 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(Phase.Done);
|
||||
},
|
||||
);
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
await waitForDeviceList();
|
||||
});
|
||||
|
||||
it("can cancel during the Ready phase", 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...
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
// now alice changes her mind
|
||||
const [requestBody] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.cancel"),
|
||||
request.cancel(),
|
||||
]);
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
expect(toDeviceMessage.code).toEqual("m.user");
|
||||
expect(request.phase).toEqual(VerificationPhase.Cancelled);
|
||||
});
|
||||
|
||||
it("can cancel during the SAS phase", 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...
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
// ... and picks a method with m.key.verification.start
|
||||
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
|
||||
// there should now be a verifier...
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.hasBeenCancelled).toBe(false);
|
||||
|
||||
// start off the verification process: alice will send an `accept`
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept");
|
||||
const verificationPromise = verifier.verify();
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
jest.advanceTimersByTime(10);
|
||||
await sendToDevicePromise;
|
||||
|
||||
// now we unceremoniously cancel. We expect the verificatationPromise to reject.
|
||||
const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel");
|
||||
verifier.cancel(new Error("blah"));
|
||||
await requestPromise;
|
||||
|
||||
// ... which should cancel the verifier
|
||||
await expect(verificationPromise).rejects.toThrow();
|
||||
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);
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
await waitForDeviceList();
|
||||
});
|
||||
|
||||
it("Incoming verification: can accept", async () => {
|
||||
const TRANSACTION_ID = "abcd";
|
||||
|
||||
// Initiate the request by sending a to-device message
|
||||
returnToDeviceMessageFromSync(buildRequestMessage(TRANSACTION_ID));
|
||||
const request: VerificationRequest = await emitPromise(
|
||||
aliceClient,
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
);
|
||||
expect(request.transactionId).toEqual(TRANSACTION_ID);
|
||||
expect(request.phase).toEqual(VerificationPhase.Requested);
|
||||
expect(request.roomId).toBeUndefined();
|
||||
expect(request.initiatedByMe).toBe(false);
|
||||
expect(request.otherUserId).toEqual(TEST_USER_ID);
|
||||
expect(request.chosenMethod).toBe(null); // nothing chosen yet
|
||||
expect(canAcceptVerificationRequest(request)).toBe(true);
|
||||
expect(request.pending).toBe(true);
|
||||
|
||||
// Alice accepts, by sending a to-device message
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready");
|
||||
const acceptPromise = request.accept();
|
||||
expect(canAcceptVerificationRequest(request)).toBe(false);
|
||||
expect(request.phase).toEqual(VerificationPhase.Requested);
|
||||
await acceptPromise;
|
||||
const requestBody = await sendToDevicePromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.methods).toContain("m.sas.v1");
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID);
|
||||
});
|
||||
|
||||
it("Incoming verification: can refuse", async () => {
|
||||
const TRANSACTION_ID = "abcd";
|
||||
|
||||
// Initiate the request by sending a to-device message
|
||||
returnToDeviceMessageFromSync(buildRequestMessage(TRANSACTION_ID));
|
||||
const request: VerificationRequest = await emitPromise(
|
||||
aliceClient,
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
);
|
||||
expect(request.transactionId).toEqual(TRANSACTION_ID);
|
||||
|
||||
// Alice declines, by sending a cancellation
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.cancel");
|
||||
const cancelPromise = request.cancel();
|
||||
expect(canAcceptVerificationRequest(request)).toBe(false);
|
||||
expect(request.accepting).toBe(false);
|
||||
expect(request.declining).toBe(true);
|
||||
await cancelPromise;
|
||||
const requestBody = await sendToDevicePromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Cancelled);
|
||||
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "device_under_test",
|
||||
...opts,
|
||||
});
|
||||
await initCrypto(client);
|
||||
await client.startClient();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void {
|
||||
ev.sender ??= TEST_USER_ID;
|
||||
@@ -396,6 +683,114 @@ function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string {
|
||||
return mac;
|
||||
}
|
||||
|
||||
/** Calculate the sha256 hash of a string, encoding as unpadded base64 */
|
||||
function sha256(commitmentStr: string): string {
|
||||
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
|
||||
}
|
||||
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
/** build an m.key.verification.request to-device message originating from the dummy device */
|
||||
function buildRequestMessage(transactionId: string): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.request",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: ["m.sas.v1"],
|
||||
transaction_id: transactionId,
|
||||
timestamp: Date.now() - 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.ready to-device message originating from the dummy device */
|
||||
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: methods,
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
method: "m.sas.v1",
|
||||
transaction_id: transactionId,
|
||||
hashes: ["sha256"],
|
||||
key_agreement_protocols: ["curve25519-hkdf-sha256"],
|
||||
message_authentication_codes: ["hkdf-hmac-sha256.v2"],
|
||||
// we have to include "decimal" per the spec.
|
||||
short_authentication_string: ["decimal", "emoji"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.accept to-device message suitable for the SAS flow */
|
||||
function buildSasAcceptMessage(transactionId: string, commitmentStr: string) {
|
||||
return {
|
||||
type: "m.key.verification.accept",
|
||||
content: {
|
||||
transaction_id: transactionId,
|
||||
commitment: sha256(commitmentStr),
|
||||
hash: "sha256",
|
||||
key_agreement_protocol: "curve25519-hkdf-sha256",
|
||||
short_authentication_string: ["decimal", "emoji"],
|
||||
message_authentication_code: "hkdf-hmac-sha256.v2",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.key to-device message suitable for the SAS flow */
|
||||
function buildSasKeyMessage(transactionId: string, key: string): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.key",
|
||||
content: {
|
||||
transaction_id: transactionId,
|
||||
key: key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.mac to-device message suitable for the SAS flow, originating from the dummy device */
|
||||
function buildSasMacMessage(
|
||||
transactionId: string,
|
||||
olmSAS: Olm.SAS,
|
||||
recipientUserId: string,
|
||||
recipientDeviceId: string,
|
||||
): { type: string; content: object } {
|
||||
const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${recipientUserId}${recipientDeviceId}${transactionId}`;
|
||||
|
||||
return {
|
||||
type: "m.key.verification.mac",
|
||||
content: {
|
||||
keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`),
|
||||
transaction_id: transactionId,
|
||||
mac: {
|
||||
[`ed25519:${TEST_DEVICE_ID}`]: calculateMAC(
|
||||
olmSAS,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
`${macInfoBase}ed25519:${TEST_DEVICE_ID}`,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.done to-device message */
|
||||
function buildDoneMessage(transactionId: string) {
|
||||
return {
|
||||
type: "m.key.verification.done",
|
||||
content: {
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -598,12 +598,6 @@ describe("MatrixClient event timelines", function () {
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
@@ -634,12 +628,6 @@ describe("MatrixClient event timelines", function () {
|
||||
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
|
||||
await httpBackend.flushAllExpected();
|
||||
const timelineSet = thread.timelineSet;
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
await flushHttp(emitPromise(thread, ThreadEvent.Update));
|
||||
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
|
||||
|
||||
@@ -1510,7 +1498,8 @@ describe("MatrixClient event timelines", function () {
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
// this has to come after THREAD_REPLY which hasn't been instantiated by us
|
||||
THREAD_REPLY2.localTimestamp += 10000000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
@@ -1570,9 +1559,6 @@ describe("MatrixClient event timelines", function () {
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.NewReply);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -1699,13 +1685,11 @@ describe("MatrixClient event timelines", function () {
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY_REACTION]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
expect(thread.length).toBe(1); // reactions don't count towards the length of a thread
|
||||
// Test thread order is unchanged
|
||||
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
@@ -2021,25 +2005,6 @@ 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)!;
|
||||
@@ -2047,71 +2012,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(thread.initialEventsFetched).toBeTruthy();
|
||||
const timelineSet = thread.timelineSet;
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.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", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.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", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return {
|
||||
start: "start_token",
|
||||
events_before: [],
|
||||
event: THREAD_ROOT,
|
||||
events_after: [],
|
||||
end: "end_token",
|
||||
state: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
from: "start_token",
|
||||
}),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Forward, from: "end_token" }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [THREAD_REPLY],
|
||||
};
|
||||
});
|
||||
|
||||
const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!);
|
||||
|
||||
httpBackend.when("GET", "/sync").respond(200, {
|
||||
next_batch: "s_5_5",
|
||||
|
||||
@@ -1386,14 +1386,11 @@ describe("MatrixClient", function () {
|
||||
expectation: {},
|
||||
},
|
||||
])("should modify power levels of $userId correctly", async ({ userId, powerLevel, expectation }) => {
|
||||
const event = {
|
||||
getType: () => "m.room.power_levels",
|
||||
getContent: () => ({
|
||||
users: {
|
||||
"alice@localhost": 50,
|
||||
},
|
||||
}),
|
||||
} as MatrixEvent;
|
||||
httpBackend!.when("GET", "/state/m.room.power_levels/").respond(200, {
|
||||
users: {
|
||||
"alice@localhost": 50,
|
||||
},
|
||||
});
|
||||
|
||||
httpBackend!
|
||||
.when("PUT", "/state/m.room.power_levels")
|
||||
@@ -1402,7 +1399,76 @@ describe("MatrixClient", function () {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client!.setPowerLevel("!room_id:server", userId, powerLevel, event);
|
||||
const prom = client!.setPowerLevel("!room_id:server", userId, powerLevel);
|
||||
await httpBackend!.flushAllExpected();
|
||||
await prom;
|
||||
});
|
||||
|
||||
it("should use power level from room state if available", async () => {
|
||||
client!.clientRunning = true;
|
||||
client!.isInitialSyncComplete = () => true;
|
||||
const room = new Room("!room_id:server", client!, client!.getUserId()!);
|
||||
room.currentState.events.set("m.room.power_levels", new Map());
|
||||
room.currentState.events.get("m.room.power_levels")!.set(
|
||||
"",
|
||||
new MatrixEvent({
|
||||
type: "m.room.power_levels",
|
||||
state_key: "",
|
||||
content: {
|
||||
users: {
|
||||
"@bob:localhost": 50,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
client!.getRoom = () => room;
|
||||
|
||||
httpBackend!
|
||||
.when("PUT", "/state/m.room.power_levels")
|
||||
.check((req) => {
|
||||
expect(req.data).toStrictEqual({
|
||||
users: {
|
||||
"@bob:localhost": 50,
|
||||
[userId]: 42,
|
||||
},
|
||||
});
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client!.setPowerLevel("!room_id:server", userId, 42);
|
||||
await httpBackend!.flushAllExpected();
|
||||
await prom;
|
||||
});
|
||||
|
||||
it("should throw error if state API errors", async () => {
|
||||
httpBackend!.when("GET", "/state/m.room.power_levels/").respond(500, {
|
||||
errcode: "ERR_DERP",
|
||||
});
|
||||
|
||||
const prom = client!.setPowerLevel("!room_id:server", userId, 42);
|
||||
await Promise.all([
|
||||
expect(prom).rejects.toMatchInlineSnapshot(`[ERR_DERP: MatrixError: [500] Unknown message]`),
|
||||
httpBackend!.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not throw error if /state/ API returns M_NOT_FOUND", async () => {
|
||||
httpBackend!.when("GET", "/state/m.room.power_levels/").respond(404, {
|
||||
errcode: "M_NOT_FOUND",
|
||||
});
|
||||
|
||||
httpBackend!
|
||||
.when("PUT", "/state/m.room.power_levels")
|
||||
.check((req) => {
|
||||
expect(req.data).toStrictEqual({
|
||||
users: {
|
||||
[userId]: 42,
|
||||
},
|
||||
});
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client!.setPowerLevel("!room_id:server", userId, 42);
|
||||
await httpBackend!.flushAllExpected();
|
||||
await prom;
|
||||
});
|
||||
|
||||
@@ -145,6 +145,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
return this.deviceKeys.keys[keyIds[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the device keys have already been uploaded, return them. Else return null.
|
||||
*/
|
||||
public getUploadedDeviceKeys(): IDeviceKeys | null {
|
||||
return this.deviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If one-time keys have already been uploaded, return them. Otherwise,
|
||||
* set up an expectation that the keys will be uploaded, and wait for
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
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 { MapWithDefault } from "../../src/utils";
|
||||
import { IDownloadKeyResult } from "../../src";
|
||||
import { IDeviceKeys } from "../../src/@types/crypto";
|
||||
import { E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
|
||||
/**
|
||||
* An object which intercepts `/keys/query` fetches via fetch-mock.
|
||||
*/
|
||||
export class E2EKeyResponder {
|
||||
private deviceKeysByUserByDevice = new MapWithDefault<string, Map<string, any>>(() => new Map());
|
||||
private e2eKeyReceiversByUser = new Map<string, E2EKeyReceiver>();
|
||||
private masterKeysByUser: Record<string, any> = {};
|
||||
private selfSigningKeysByUser: Record<string, any> = {};
|
||||
private userSigningKeysByUser: Record<string, any> = {};
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl.
|
||||
* Only /query requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
private onKeyQueryRequest(options: RequestInit) {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const usersToReturn = Object.keys(content["device_keys"]);
|
||||
const response = {
|
||||
device_keys: {} as { [userId: string]: any },
|
||||
master_keys: {} as { [userId: string]: any },
|
||||
self_signing_keys: {} as { [userId: string]: any },
|
||||
user_signing_keys: {} as { [userId: string]: any },
|
||||
failures: {} as { [serverName: string]: any },
|
||||
};
|
||||
for (const user of usersToReturn) {
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
||||
}
|
||||
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||
if (e2eKeyReceiver !== undefined) {
|
||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||
if (deviceKeys !== null) {
|
||||
response.device_keys[user] ??= {};
|
||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||
response.master_keys[user] = this.masterKeysByUser[user];
|
||||
}
|
||||
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
|
||||
}
|
||||
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
|
||||
*
|
||||
* @param data cross-signing data
|
||||
*/
|
||||
public addCrossSigningData(
|
||||
data: Pick<IDownloadKeyResult, "master_keys" | "self_signing_keys" | "user_signing_keys">,
|
||||
) {
|
||||
Object.assign(this.masterKeysByUser, data.master_keys);
|
||||
Object.assign(this.selfSigningKeysByUser, data.self_signing_keys);
|
||||
Object.assign(this.userSigningKeysByUser, data.user_signing_keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an E2EKeyReceiver to poll for uploaded keys
|
||||
*
|
||||
* Any keys which have been uploaded to the given `E2EKeyReceiver` at the time of the `/keys/query` request will
|
||||
* be added to the response.
|
||||
*
|
||||
* @param e2eKeyReceiver
|
||||
*/
|
||||
public addKeyReceiver(userId: string, e2eKeyReceiver: E2EKeyReceiver) {
|
||||
this.e2eKeyReceiversByUser.set(userId, e2eKeyReceiver);
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,28 @@ export function mockInitialApiRequests(homeserverUrl: string) {
|
||||
filter_id: "fid",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return `{}` for `GET _matrix/client/r0/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", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -157,7 +157,7 @@ export const mkThread = ({
|
||||
room?.reEmitter.reEmit(evt, [MatrixEventEvent.BeforeRedaction]);
|
||||
}
|
||||
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true);
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, [rootEvent, ...events], true);
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
|
||||
@@ -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 { M_AUTHENTICATION } from "../../src";
|
||||
import { AutoDiscovery } from "../../src/autodiscovery";
|
||||
import { OidcDiscoveryError } from "../../src/oidc/validate";
|
||||
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([
|
||||
@@ -400,7 +411,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -441,7 +452,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -485,7 +496,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "FAIL_ERROR",
|
||||
error: OidcDiscoveryError.Misconfigured,
|
||||
error: OidcError.Misconfigured,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -719,7 +730,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -775,7 +786,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -855,4 +866,75 @@ describe("AutoDiscovery", function () {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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: ["r0.0.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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import {
|
||||
DuplicateStrategy,
|
||||
@@ -160,6 +162,33 @@ describe("EventTimelineSet", () => {
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should aggregate relations which belong to unknown timeline without adding them to any timeline", () => {
|
||||
// If threads are disabled all events go into the main timeline
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
const reactionEvent = utils.mkReaction(messageEvent, client, client.getSafeUserId(), roomId);
|
||||
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents()).toHaveLength(1);
|
||||
const [event] = liveTimeline.getEvents();
|
||||
const reactions = eventTimelineSet.relations!.getChildEventsForEvent(
|
||||
event.getId()!,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)!;
|
||||
const relations = reactions.getRelations();
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0].getId()).toBe(reactionEvent.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventToTimeline (thread timeline)", () => {
|
||||
|
||||
@@ -94,7 +94,6 @@ describe("InteractiveAuth", () => {
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Password] }],
|
||||
errcode: "MockError0",
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
|
||||
@@ -308,4 +308,25 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore thread relation on state events", async () => {
|
||||
const stateEvent = new MatrixEvent({
|
||||
event_id: "$event_id",
|
||||
type: "some_state_event",
|
||||
content: {
|
||||
"foo": "bar",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread_id",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread_id",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
state_key: "",
|
||||
});
|
||||
|
||||
expect(stateEvent.isState()).toBeTruthy();
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { mocked } from "jest-mock";
|
||||
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
@@ -43,6 +43,7 @@ describe("Thread", () => {
|
||||
const myUserId = "@bob:example.org";
|
||||
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { timelineSupport: false });
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -300,6 +301,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -354,6 +356,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -405,6 +408,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -699,11 +703,7 @@ async function createThread(client: MatrixClient, user: string, roomId: string):
|
||||
root.setThreadId(root.getId());
|
||||
await room.addLiveEvents([root]);
|
||||
|
||||
// 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;
|
||||
return room.createThread(root.getId()!, root, [], false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
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 { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { Method } from "../../../src";
|
||||
import * as crypto from "../../../src/crypto/crypto";
|
||||
import { logger } from "../../../src/logger";
|
||||
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");
|
||||
|
||||
// save for resetting mocks
|
||||
const realSubtleCrypto = crypto.subtleCrypto;
|
||||
|
||||
describe("oidc authorization", () => {
|
||||
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(() => {
|
||||
// @ts-ignore reset any ugly mocking we did
|
||||
crypto.subtleCrypto = realSubtleCrypto;
|
||||
});
|
||||
|
||||
it("should generate authorization params", () => {
|
||||
const result = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
|
||||
expect(result.redirectUri).toEqual(baseUrl);
|
||||
|
||||
// random strings
|
||||
expect(result.state.length).toEqual(8);
|
||||
expect(result.nonce.length).toEqual(8);
|
||||
expect(result.codeVerifier.length).toEqual(64);
|
||||
|
||||
const expectedScope =
|
||||
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:";
|
||||
expect(result.scope.startsWith(expectedScope)).toBeTruthy();
|
||||
// deviceId of 10 characters is appended to the device scope
|
||||
expect(result.scope.length).toEqual(expectedScope.length + 10);
|
||||
});
|
||||
|
||||
describe("generateAuthorizationUrl()", () => {
|
||||
it("should generate url with correct parameters", async () => {
|
||||
// test the no crypto case here
|
||||
// @ts-ignore mocking
|
||||
crypto.subtleCrypto = undefined;
|
||||
|
||||
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
const authUrl = new URL(
|
||||
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
|
||||
);
|
||||
|
||||
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");
|
||||
expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope);
|
||||
expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state);
|
||||
expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce);
|
||||
|
||||
// crypto not available, plain text code_challenge is used
|
||||
expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"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;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
const homeserverUrl = "https://server.org/";
|
||||
const identityServerUrl = "https://id.org/";
|
||||
const nonce = "test-nonce";
|
||||
const redirectUri = baseUrl;
|
||||
const code = "auth_code_xyz";
|
||||
const validBearerTokenResponse = {
|
||||
token_type: "Bearer",
|
||||
access_token: "test_access_token",
|
||||
refresh_token: "test_refresh_token",
|
||||
id_token: "valid.id.token",
|
||||
expires_in: 300,
|
||||
};
|
||||
|
||||
const metadata = mockOpenIdConfiguration();
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
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.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
|
||||
fetchMock.get(`${metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
mockSessionStorage({});
|
||||
|
||||
mocked(jwtDecode).mockReturnValue(validDecodedIdToken);
|
||||
});
|
||||
|
||||
it("should make correct request to the token endpoint", async () => {
|
||||
const state = await setupState();
|
||||
const codeVerifier = getValueFromStorage(state, "code_verifier");
|
||||
await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
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 state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
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,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...tokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
});
|
||||
|
||||
it("should throw when state is not found in storage", async () => {
|
||||
// don't setup sessionStorage with expected state
|
||||
const state = "abc123";
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
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(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...invalidBearerTokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
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, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidIdToken),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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 fetchMockJest from "fetch-mock-jest";
|
||||
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
import { registerOidcClient } from "../../../src/oidc/register";
|
||||
|
||||
describe("registerOidcClient()", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const registrationEndpoint = "https://auth.com/register";
|
||||
const clientName = "Element";
|
||||
const baseUrl = "https://just.testing";
|
||||
const dynamicClientId = "xyz789";
|
||||
|
||||
const delegatedAuthConfig = {
|
||||
issuer,
|
||||
registrationEndpoint,
|
||||
authorizationEndpoint: issuer + "auth",
|
||||
tokenEndpoint: issuer + "token",
|
||||
};
|
||||
beforeEach(() => {
|
||||
fetchMockJest.mockClear();
|
||||
fetchMockJest.resetBehavior();
|
||||
});
|
||||
|
||||
it("should make correct request to register client", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
|
||||
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_name: clientName,
|
||||
client_uri: baseUrl,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
redirect_uris: [baseUrl],
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when registration request fails", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 500,
|
||||
});
|
||||
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationFailed,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when registration response is invalid", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
// no clientId in response
|
||||
body: "{}",
|
||||
});
|
||||
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
});
|
||||
+119
-29
@@ -14,13 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { M_AUTHENTICATION } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
validateIdToken,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "../../../src/oidc/validate";
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
|
||||
jest.mock("jwt-decode");
|
||||
|
||||
describe("validateWellKnownAuthentication()", () => {
|
||||
const baseWk = {
|
||||
@@ -29,7 +35,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
},
|
||||
};
|
||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported);
|
||||
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||
@@ -39,7 +45,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: { url: "test.com" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||
@@ -50,7 +58,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: { url: "test" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is false", () => {
|
||||
@@ -61,7 +71,9 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
account: false,
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses stable m.authentication", () => {
|
||||
@@ -72,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",
|
||||
});
|
||||
@@ -85,7 +97,7 @@ describe("validateWellKnownAuthentication()", () => {
|
||||
issuer: "test.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
@@ -98,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", () => {
|
||||
@@ -123,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"],
|
||||
@@ -137,7 +136,7 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
it("should throw OP support error when wellKnown is not an object", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown([]);
|
||||
}).toThrow(OidcDiscoveryError.OpSupport);
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
|
||||
});
|
||||
|
||||
@@ -148,11 +147,9 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
authorization_endpoint: undefined,
|
||||
response_types_supported: [],
|
||||
});
|
||||
}).toThrow(OidcDiscoveryError.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.",
|
||||
);
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
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", () => {
|
||||
@@ -194,6 +191,99 @@ describe("validateOIDCIssuerWellKnown", () => {
|
||||
...validWk,
|
||||
[key]: value,
|
||||
};
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport);
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateIdToken()", () => {
|
||||
const nonce = "test-nonce";
|
||||
const issuer = "https://auth.org/issuer";
|
||||
const clientId = "test-client-id";
|
||||
const idToken = "test-id-token";
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 5555,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
iss: issuer,
|
||||
};
|
||||
beforeEach(() => {
|
||||
mocked(jwtDecode).mockClear().mockReturnValue(validDecodedIdToken);
|
||||
|
||||
jest.spyOn(logger, "error").mockClear();
|
||||
});
|
||||
|
||||
it("should throw when idToken is falsy", () => {
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when idToken cannot be decoded", () => {
|
||||
mocked(jwtDecode).mockImplementation(() => {
|
||||
throw new Error("oh no!");
|
||||
});
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when issuer does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
iss: "https://badissuer.com",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid issuer"));
|
||||
});
|
||||
|
||||
it("should throw when audience does not include clientId", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: "qwerty,uiop,asdf",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when audience includes clientId and other audiences", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: `${clientId},uiop,asdf`,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when nonce does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
nonce: "something else",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid nonce"));
|
||||
});
|
||||
|
||||
it("should throw when token does not have an expiry", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
exp: undefined,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should throw when token is expired", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
// expired in the past
|
||||
exp: Date.now() / 1000 - 777,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should not throw for a valid id token", () => {
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -55,6 +55,7 @@ import * as threadUtils from "../test-utils/thread";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger";
|
||||
import { IMessageOpts } from "../test-utils/test-utils";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
|
||||
describe("Room", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -2786,10 +2787,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());
|
||||
|
||||
@@ -3388,6 +3389,23 @@ describe("Room", function () {
|
||||
const poll = room.polls.get(pollStartEventId);
|
||||
expect(poll?.pollId).toBe(pollStartEventId);
|
||||
});
|
||||
|
||||
it("removes poll from state when redacted", async () => {
|
||||
const pollStartEvent = makePollStart("1");
|
||||
const events = [pollStartEvent];
|
||||
|
||||
await room.processPollEvents(events);
|
||||
|
||||
expect(room.polls.get(pollStartEvent.getId()!)).toBeTruthy();
|
||||
|
||||
const redactedEvent = new MatrixEvent({ type: "m.room.redaction" });
|
||||
pollStartEvent.makeRedacted(redactedEvent);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// removed from poll state
|
||||
expect(room.polls.get(pollStartEvent.getId()!)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPredecessorRoomId", () => {
|
||||
|
||||
@@ -14,28 +14,30 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import {
|
||||
CryptoEvent,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IHttpOpts,
|
||||
IToDeviceEvent,
|
||||
MatrixClient,
|
||||
MatrixHttpApi,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { ImportRoomKeysOpts } from "../../../src/crypto-api";
|
||||
|
||||
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();
|
||||
});
|
||||
import { CryptoCallbacks, ImportRoomKeysOpts, VerificationRequest } from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
@@ -44,9 +46,13 @@ describe("RustCrypto", () => {
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
beforeEach(
|
||||
async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
},
|
||||
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
|
||||
10000,
|
||||
);
|
||||
|
||||
it("should import and export keys", async () => {
|
||||
const someRoomKeys = [
|
||||
@@ -135,6 +141,27 @@ describe("RustCrypto", () => {
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
const toDeviceEvent = {
|
||||
type: "m.key.verification.request",
|
||||
content: {
|
||||
from_device: "testDeviceId",
|
||||
methods: ["m.sas.v1"],
|
||||
transaction_id: "testTxn",
|
||||
timestamp: Date.now() - 1000,
|
||||
},
|
||||
sender: "@user:id",
|
||||
};
|
||||
|
||||
const onEvent = jest.fn();
|
||||
rustCrypto.on(CryptoEvent.VerificationRequestReceived, onEvent);
|
||||
await rustCrypto.preprocessToDeviceMessages([toDeviceEvent]);
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [req]: [VerificationRequest] = onEvent.mock.lastCall;
|
||||
expect(req.transactionId).toEqual("testTxn");
|
||||
});
|
||||
});
|
||||
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
@@ -211,6 +238,7 @@ describe("RustCrypto", () => {
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
});
|
||||
@@ -333,6 +361,7 @@ describe("RustCrypto", () => {
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -356,6 +385,141 @@ describe("RustCrypto", () => {
|
||||
expect(res).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userHasCrossSigningKeys", () => {
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRecoveryKeyFromPassphrase", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should create a recovery key without password", async () => {
|
||||
const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase();
|
||||
|
||||
// Expected the encoded private key to have 59 chars
|
||||
expect(recoveryKey.encodedPrivateKey?.length).toBe(59);
|
||||
// Expect the private key to be an Uint8Array with a length of 32
|
||||
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(recoveryKey.privateKey.length).toBe(32);
|
||||
// Expect keyInfo to be empty
|
||||
expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0);
|
||||
});
|
||||
|
||||
it("should create a recovery key with password", async () => {
|
||||
const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase("my password");
|
||||
|
||||
// Expected the encoded private key to have 59 chars
|
||||
expect(recoveryKey.encodedPrivateKey?.length).toBe(59);
|
||||
// Expect the private key to be an Uint8Array with a length of 32
|
||||
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(recoveryKey.privateKey.length).toBe(32);
|
||||
// Expect keyInfo.passphrase to be filled
|
||||
expect(recoveryKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2");
|
||||
expect(recoveryKey.keyInfo?.passphrase?.iterations).toBe(500000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should wait for a keys/query before returning devices", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", {
|
||||
device_keys: {
|
||||
[testData.TEST_USER_ID]: {
|
||||
[testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID);
|
||||
|
||||
// an attempt to fetch the device list should block
|
||||
const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
|
||||
|
||||
// ... until a /sync completes, and we trigger the outgoingRequests.
|
||||
rustCrypto.onSyncCompleted({});
|
||||
|
||||
const deviceMap = (await devicesPromise).get(testData.TEST_USER_ID)!;
|
||||
expect(deviceMap.has(TEST_DEVICE_ID)).toBe(true);
|
||||
expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true);
|
||||
rustCrypto.stop();
|
||||
});
|
||||
|
||||
describe("requestDeviceVerification", () => {
|
||||
it("throws an error if the device is unknown", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(() => rustCrypto.requestDeviceVerification(TEST_USER, "unknown")).rejects.toThrow(
|
||||
"Not a known device",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
@@ -367,6 +531,7 @@ async function makeTestRustCrypto(
|
||||
userId: string = TEST_USER,
|
||||
deviceId: string = TEST_DEVICE_ID,
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage);
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks, null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { RustVerificationRequest } from "../../../src/rust-crypto/verification";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("VerificationRequest", () => {
|
||||
describe("pending", () => {
|
||||
let request: RustVerificationRequest;
|
||||
let mockedInner: Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedInner = makeMockedInner();
|
||||
request = makeTestRequest(mockedInner);
|
||||
});
|
||||
|
||||
it("returns true for a created request", () => {
|
||||
expect(request.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for passive requests", () => {
|
||||
mockedInner.isPassive.mockReturnValue(true);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for completed requests", () => {
|
||||
mockedInner.phase.mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Done);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for cancelled requests", () => {
|
||||
mockedInner.phase.mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Cancelled);
|
||||
expect(request.pending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeout", () => {
|
||||
it("passes through the result", () => {
|
||||
const mockedInner = makeMockedInner();
|
||||
const request = makeTestRequest(mockedInner);
|
||||
mockedInner.timeRemainingMillis.mockReturnValue(10_000);
|
||||
expect(request.timeout).toEqual(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startVerification", () => {
|
||||
let request: RustVerificationRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
request = makeTestRequest();
|
||||
});
|
||||
|
||||
it("does not permit methods other than SAS", async () => {
|
||||
await expect(request.startVerification("m.reciprocate.v1")).rejects.toThrow(
|
||||
"Unsupported verification method",
|
||||
);
|
||||
});
|
||||
|
||||
it("raises an error if starting verification does not produce a verifier", async () => {
|
||||
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
|
||||
"Still no verifier after startSas() call",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a RustVerificationRequest with default parameters */
|
||||
function makeTestRequest(
|
||||
inner?: RustSdkCryptoJs.VerificationRequest,
|
||||
outgoingRequestProcessor?: OutgoingRequestProcessor,
|
||||
): RustVerificationRequest {
|
||||
inner ??= makeMockedInner();
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(inner, outgoingRequestProcessor, undefined);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
function makeMockedInner(): Mocked<RustSdkCryptoJs.VerificationRequest> {
|
||||
return {
|
||||
registerChangesCallback: jest.fn(),
|
||||
startSas: jest.fn(),
|
||||
phase: jest.fn().mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Created),
|
||||
isPassive: jest.fn().mockReturnValue(false),
|
||||
timeRemainingMillis: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.VerificationRequest>;
|
||||
}
|
||||
+155
-13
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { IClientWellKnown } from "../client";
|
||||
|
||||
// disable lint because these are wire responses
|
||||
/* eslint-disable camelcase */
|
||||
@@ -79,19 +80,6 @@ export interface IIdentityProvider {
|
||||
brand?: IdentityProviderBrand | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
export interface ILoginParams {
|
||||
identifier?: object;
|
||||
password?: string;
|
||||
token?: string;
|
||||
device_id?: string;
|
||||
initial_device_display_name?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export enum SSOAction {
|
||||
/** The user intends to login to an existing account */
|
||||
LOGIN = "login",
|
||||
@@ -100,6 +88,160 @@ export enum SSOAction {
|
||||
REGISTER = "register",
|
||||
}
|
||||
|
||||
/**
|
||||
* A client can identify a user using their Matrix ID.
|
||||
* This can either be the fully qualified Matrix user ID, or just the localpart of the user ID.
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#matrix-user-id
|
||||
*/
|
||||
type UserLoginIdentifier = {
|
||||
type: "m.id.user";
|
||||
user: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A client can identify a user using a 3PID associated with the user’s account on the homeserver,
|
||||
* where the 3PID was previously associated using the /account/3pid API.
|
||||
* See the 3PID Types Appendix for a list of Third-party ID media.
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#third-party-id
|
||||
*/
|
||||
type ThirdPartyLoginIdentifier = {
|
||||
type: "m.id.thirdparty";
|
||||
medium: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A client can identify a user using a phone number associated with the user’s account,
|
||||
* where the phone number was previously associated using the /account/3pid API.
|
||||
* The phone number can be passed in as entered by the user; the homeserver will be responsible for canonicalising it.
|
||||
* If the client wishes to canonicalise the phone number,
|
||||
* then it can use the m.id.thirdparty identifier type with a medium of msisdn instead.
|
||||
*
|
||||
* The country is the two-letter uppercase ISO-3166-1 alpha-2 country code that the number in phone should be parsed as if it were dialled from.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#phone-number
|
||||
*/
|
||||
type PhoneLoginIdentifier = {
|
||||
type: "m.id.phone";
|
||||
country: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type SpecUserIdentifier = UserLoginIdentifier | ThirdPartyLoginIdentifier | PhoneLoginIdentifier;
|
||||
|
||||
/**
|
||||
* User Identifiers usable for login & user-interactive authentication.
|
||||
*
|
||||
* Extensibly allows more than Matrix specified identifiers.
|
||||
*/
|
||||
export type UserIdentifier =
|
||||
| SpecUserIdentifier
|
||||
| { type: Exclude<string, SpecUserIdentifier["type"]>; [key: string]: any };
|
||||
|
||||
/**
|
||||
* Request body for POST /login request
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3login
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
/**
|
||||
* The login type being used.
|
||||
*/
|
||||
type: "m.login.password" | "m.login.token" | string;
|
||||
/**
|
||||
* Third-party identifier for the user.
|
||||
* @deprecated in favour of `identifier`.
|
||||
*/
|
||||
address?: string;
|
||||
/**
|
||||
* ID of the client device.
|
||||
* If this does not correspond to a known client device, a new device will be created.
|
||||
* The given device ID must not be the same as a cross-signing key ID.
|
||||
* The server will auto-generate a device_id if this is not specified.
|
||||
*/
|
||||
device_id?: string;
|
||||
/**
|
||||
* Identification information for a user
|
||||
*/
|
||||
identifier?: UserIdentifier;
|
||||
/**
|
||||
* A display name to assign to the newly-created device.
|
||||
* Ignored if device_id corresponds to a known device.
|
||||
*/
|
||||
initial_device_display_name?: string;
|
||||
/**
|
||||
* When logging in using a third-party identifier, the medium of the identifier.
|
||||
* Must be `email`.
|
||||
* @deprecated in favour of `identifier`.
|
||||
*/
|
||||
medium?: "email";
|
||||
/**
|
||||
* Required when type is `m.login.password`. The user’s password.
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* If true, the client supports refresh tokens.
|
||||
*/
|
||||
refresh_token?: boolean;
|
||||
/**
|
||||
* Required when type is `m.login.token`. Part of Token-based login.
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* The fully qualified user ID or just local part of the user ID, to log in.
|
||||
* @deprecated in favour of identifier.
|
||||
*/
|
||||
user?: string;
|
||||
// Extensible
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Export for backwards compatibility
|
||||
export type ILoginParams = LoginRequest;
|
||||
|
||||
/**
|
||||
* Response body for POST /login request
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3login
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/**
|
||||
* An access token for the account.
|
||||
* This access token can then be used to authorize other requests.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* ID of the logged-in device.
|
||||
* Will be the same as the corresponding parameter in the request, if one was specified.
|
||||
*/
|
||||
device_id: string;
|
||||
/**
|
||||
* The fully-qualified Matrix ID for the account.
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* The lifetime of the access token, in milliseconds.
|
||||
* Once the access token has expired a new access token can be obtained by using the provided refresh token.
|
||||
* If no refresh token is provided, the client will need to re-log in to obtain a new access token.
|
||||
* If not given, the client can assume that the access token will not expire.
|
||||
*/
|
||||
expires_in_ms?: number;
|
||||
/**
|
||||
* A refresh token for the account.
|
||||
* This token can be used to obtain a new access token when it expires by calling the /refresh endpoint.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
/**
|
||||
* Optional client configuration provided by the server.
|
||||
* If present, clients SHOULD use the provided object to reconfigure themselves, optionally validating the URLs within.
|
||||
* This object takes the same form as the one returned from .well-known autodiscovery.
|
||||
*/
|
||||
well_known?: IClientWellKnown;
|
||||
/**
|
||||
* The server_name of the homeserver on which the account has been registered.
|
||||
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
|
||||
*/
|
||||
home_server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
|
||||
* `m.login.token` issuance request.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 { AuthDict } from "../interactive-auth";
|
||||
|
||||
/**
|
||||
* The request body of a call to `POST /_matrix/client/v3/register`.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3register
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
/**
|
||||
* Additional authentication information for the user-interactive authentication API.
|
||||
* Note that this information is not used to define how the registered user should be authenticated,
|
||||
* but is instead used to authenticate the register call itself.
|
||||
*/
|
||||
auth?: AuthDict;
|
||||
/**
|
||||
* The basis for the localpart of the desired Matrix ID.
|
||||
* If omitted, the homeserver MUST generate a Matrix ID local part.
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* The desired password for the account.
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* If true, the client supports refresh tokens.
|
||||
*/
|
||||
refresh_token?: boolean;
|
||||
/**
|
||||
* If true, an access_token and device_id should not be returned from this call, therefore preventing an automatic login.
|
||||
* Defaults to false.
|
||||
*/
|
||||
inhibit_login?: boolean;
|
||||
/**
|
||||
* A display name to assign to the newly-created device.
|
||||
* Ignored if device_id corresponds to a known device.
|
||||
*/
|
||||
initial_device_display_name?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
guest_access_token?: string;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
x_show_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_msisdn?: boolean;
|
||||
/**
|
||||
* @deprecated missing in the spec
|
||||
*/
|
||||
bind_email?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a successful call to `POST /_matrix/client/v3/register`.
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3register
|
||||
*/
|
||||
export interface RegisterResponse {
|
||||
/**
|
||||
* The fully-qualified Matrix user ID (MXID) that has been registered.
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* An access token for the account.
|
||||
* This access token can then be used to authorize other requests.
|
||||
* Required if the inhibit_login option is false.
|
||||
*/
|
||||
access_token?: string;
|
||||
/**
|
||||
* ID of the registered device.
|
||||
* Will be the same as the corresponding parameter in the request, if one was specified.
|
||||
* Required if the inhibit_login option is false.
|
||||
*/
|
||||
device_id?: string;
|
||||
/**
|
||||
* The lifetime of the access token, in milliseconds.
|
||||
* Once the access token has expired a new access token can be obtained by using the provided refresh token.
|
||||
* If no refresh token is provided, the client will need to re-log in to obtain a new access token.
|
||||
* If not given, the client can assume that the access token will not expire.
|
||||
*
|
||||
* Omitted if the inhibit_login option is true.
|
||||
*/
|
||||
expires_in_ms?: number;
|
||||
/**
|
||||
* A refresh token for the account.
|
||||
* This token can be used to obtain a new access token when it expires by calling the /refresh endpoint.
|
||||
*
|
||||
* Omitted if the inhibit_login option is true.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
/**
|
||||
* The server_name of the homeserver on which the account has been registered.
|
||||
*
|
||||
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
|
||||
*/
|
||||
home_server?: string;
|
||||
}
|
||||
@@ -176,7 +176,8 @@ export interface IAddThreePidOnlyBody {
|
||||
export interface IBindThreePidBody {
|
||||
client_secret: string;
|
||||
id_server: string;
|
||||
id_access_token: string;
|
||||
// Some older identity servers have no auth enabled
|
||||
id_access_token: string | null;
|
||||
sid: string;
|
||||
}
|
||||
|
||||
|
||||
+70
-12
@@ -15,15 +15,19 @@ 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 { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
ValidatedIssuerConfig,
|
||||
ValidatedIssuerMetadata,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "./oidc/validate";
|
||||
import { OidcError } from "./oidc/error";
|
||||
|
||||
// Dev note: Auto discovery is part of the spec.
|
||||
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
|
||||
@@ -54,12 +58,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +284,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)
|
||||
@@ -275,6 +293,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
|
||||
@@ -288,7 +307,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,
|
||||
@@ -297,7 +317,7 @@ export class AutoDiscovery {
|
||||
|
||||
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
|
||||
logger.error("Failed to fetch issuer openid configuration");
|
||||
throw new Error(OidcDiscoveryError.General);
|
||||
throw new Error(OidcError.General);
|
||||
}
|
||||
|
||||
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
|
||||
@@ -310,15 +330,53 @@ export class AutoDiscovery {
|
||||
};
|
||||
return delegatedAuthConfig;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message as unknown as OidcDiscoveryError;
|
||||
const errorType = Object.values(OidcDiscoveryError).includes(errorMessage)
|
||||
? errorMessage
|
||||
: OidcDiscoveryError.General;
|
||||
const errorMessage = (error as Error).message as unknown as OidcError;
|
||||
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
|
||||
|
||||
const state =
|
||||
errorType === OidcDiscoveryError.NotSupported
|
||||
? AutoDiscoveryAction.IGNORE
|
||||
: AutoDiscoveryAction.FAIL_ERROR;
|
||||
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
|
||||
|
||||
return {
|
||||
state,
|
||||
error: errorType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
+143
-78
@@ -101,7 +101,7 @@ import {
|
||||
import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
|
||||
import { IAuthData, IAuthDict } from "./interactive-auth";
|
||||
import { AuthDict } from "./interactive-auth";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
|
||||
import { CrossSigningKey, ICreateSecretStorageOpts, IEncryptedEventInfo, IRecoveryKey } from "./crypto/api";
|
||||
import { EventTimelineSet } from "./models/event-timeline-set";
|
||||
@@ -111,7 +111,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,
|
||||
@@ -178,7 +178,14 @@ import { IThreepid } from "./@types/threepids";
|
||||
import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
|
||||
import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
|
||||
import { MediaHandler } from "./webrtc/mediaHandler";
|
||||
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
|
||||
import {
|
||||
LoginTokenPostResponse,
|
||||
ILoginFlowsResponse,
|
||||
IRefreshTokenResponse,
|
||||
SSOAction,
|
||||
LoginResponse,
|
||||
LoginRequest,
|
||||
} from "./@types/auth";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
|
||||
import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
|
||||
@@ -209,6 +216,7 @@ import {
|
||||
ServerSideSecretStorage,
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "./secret-storage";
|
||||
import { RegisterRequest, RegisterResponse } from "./@types/registration";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -336,6 +344,11 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
pickleKey?: string;
|
||||
|
||||
/**
|
||||
* Verification methods we should offer to the other side when performing an interactive verification.
|
||||
* If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS
|
||||
* (aka "emojis").
|
||||
*/
|
||||
verificationMethods?: Array<VerificationMethod>;
|
||||
|
||||
/**
|
||||
@@ -367,6 +380,9 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
useE2eForGroupCall?: boolean;
|
||||
|
||||
/**
|
||||
* Crypto callbacks provided by the application
|
||||
*/
|
||||
cryptoCallbacks?: ICryptoCallbacks;
|
||||
|
||||
/**
|
||||
@@ -709,18 +725,8 @@ interface IJoinedMembersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface IRegisterRequestParams {
|
||||
auth?: IAuthDict;
|
||||
username?: string;
|
||||
password?: string;
|
||||
refresh_token?: boolean;
|
||||
guest_access_token?: string;
|
||||
x_show_msisdn?: boolean;
|
||||
bind_msisdn?: boolean;
|
||||
bind_email?: boolean;
|
||||
inhibit_login?: boolean;
|
||||
initial_device_display_name?: string;
|
||||
}
|
||||
// Re-export for backwards compatibility
|
||||
export type IRegisterRequestParams = RegisterRequest;
|
||||
|
||||
export interface IPublicRoomsChunkRoom {
|
||||
room_id: string;
|
||||
@@ -863,6 +869,7 @@ export interface TimestampToEventResponse {
|
||||
interface IWhoamiResponse {
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
is_guest?: boolean;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
@@ -915,6 +922,7 @@ type CryptoEvents =
|
||||
| CryptoEvent.RoomKeyRequest
|
||||
| CryptoEvent.RoomKeyRequestCancellation
|
||||
| CryptoEvent.VerificationRequest
|
||||
| CryptoEvent.VerificationRequestReceived
|
||||
| CryptoEvent.DeviceVerificationChanged
|
||||
| CryptoEvent.UserTrustStatusChanged
|
||||
| CryptoEvent.KeysChanged
|
||||
@@ -2196,10 +2204,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @experimental
|
||||
*
|
||||
* @param useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
*
|
||||
* @returns a Promise which will resolve when the crypto layer has been
|
||||
* successfully initialised.
|
||||
*/
|
||||
public async initRustCrypto(): Promise<void> {
|
||||
public async initRustCrypto({ useIndexedDB = true }: { useIndexedDB?: boolean } = {}): Promise<void> {
|
||||
if (this.cryptoBackend) {
|
||||
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
@@ -2223,11 +2233,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
|
||||
// needed.
|
||||
const RustCrypto = await import("./rust-crypto");
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(
|
||||
this.http,
|
||||
userId,
|
||||
deviceId,
|
||||
this.secretStorage,
|
||||
this.cryptoCallbacks,
|
||||
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
|
||||
);
|
||||
rustCrypto.supportedVerificationMethods = this.verificationMethods;
|
||||
|
||||
this.cryptoBackend = rustCrypto;
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
|
||||
|
||||
// re-emit the events emitted by the crypto impl
|
||||
this.reEmitter.reEmit(rustCrypto, [CryptoEvent.VerificationRequestReceived]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2431,12 +2453,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
* @deprecated Prefer {@link CryptoApi.findVerificationRequestDMInProgress}.
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
} else if (!this.crypto) {
|
||||
// Hack for element-R to avoid breaking the cypress tests. We can get rid of this once the react-sdk is
|
||||
// updated to use CryptoApi.findVerificationRequestDMInProgress.
|
||||
return undefined;
|
||||
}
|
||||
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
|
||||
return this.crypto.findVerificationRequestDMInProgress(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2445,6 +2472,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param userId - the ID of the user to query
|
||||
*
|
||||
* @returns the VerificationRequests that are in progress
|
||||
* @deprecated Prefer {@link CryptoApi.getVerificationRequestsToDeviceInProgress}.
|
||||
*/
|
||||
public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
|
||||
if (!this.crypto) {
|
||||
@@ -2462,6 +2490,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#requestOwnUserVerification} or {@link CryptoApi#requestDeviceVerification}.
|
||||
*/
|
||||
public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
|
||||
if (!this.crypto) {
|
||||
@@ -2866,6 +2896,7 @@ 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}.
|
||||
*/
|
||||
public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
@@ -4225,24 +4256,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) {
|
||||
@@ -4252,10 +4307,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, "");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4940,9 +4992,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
|
||||
if (!unthreaded) {
|
||||
const isThread = !!event.threadRootId;
|
||||
// A thread cannot be just a thread root and a thread root can only be read in the main timeline
|
||||
const isThread = !!event.threadRootId && !event.isThreadRoot;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -7620,7 +7674,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param bindThreepids - Set key 'email' to true to bind any email
|
||||
* threepid uses during registration in the identity server. Set 'msisdn' to
|
||||
* true to bind msisdn.
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Promise which resolves to a RegisterResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public register(
|
||||
@@ -7631,7 +7685,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean },
|
||||
guestAccessToken?: string,
|
||||
inhibitLogin?: boolean,
|
||||
): Promise<IAuthData> {
|
||||
): Promise<RegisterResponse> {
|
||||
// backwards compat
|
||||
if (bindThreepids === true) {
|
||||
bindThreepids = { email: true };
|
||||
@@ -7642,7 +7696,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
auth.session = sessionId;
|
||||
}
|
||||
|
||||
const params: IRegisterRequestParams = {
|
||||
const params: RegisterRequest = {
|
||||
auth: auth,
|
||||
refresh_token: true, // always ask for a refresh token - does nothing if unsupported
|
||||
};
|
||||
@@ -7698,8 +7752,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* `{ user_id, device_id, access_token, home_server }`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public registerGuest({ body }: { body?: any } = {}): Promise<any> {
|
||||
// TODO: Types
|
||||
public registerGuest({ body }: { body?: RegisterRequest } = {}): Promise<RegisterResponse> {
|
||||
return this.registerRequest(body || {}, "guest");
|
||||
}
|
||||
|
||||
@@ -7709,7 +7762,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to the /register response
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public registerRequest(data: IRegisterRequestParams, kind?: string): Promise<IAuthData> {
|
||||
public registerRequest(data: RegisterRequest, kind?: string): Promise<RegisterResponse> {
|
||||
const params: { kind?: string } = {};
|
||||
if (kind) {
|
||||
params.kind = kind;
|
||||
@@ -7762,23 +7815,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Promise which resolves to a LoginResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public login(loginType: string, data: any): Promise<any> {
|
||||
// TODO: Types
|
||||
const loginData = {
|
||||
type: loginType,
|
||||
};
|
||||
|
||||
// merge data into loginData
|
||||
Object.assign(loginData, data);
|
||||
|
||||
public login(loginType: LoginRequest["type"], data: Omit<LoginRequest, "type">): Promise<LoginResponse> {
|
||||
return this.http
|
||||
.authedRequest<{
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
}>(Method.Post, "/login", undefined, loginData)
|
||||
.authedRequest<LoginResponse>(Method.Post, "/login", undefined, {
|
||||
...data,
|
||||
type: loginType,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.access_token && response.user_id) {
|
||||
this.http.opts.accessToken = response.access_token;
|
||||
@@ -7791,11 +7836,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Promise which resolves to a LoginResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public loginWithPassword(user: string, password: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public loginWithPassword(user: string, password: string): Promise<LoginResponse> {
|
||||
return this.login("m.login.password", {
|
||||
user: user,
|
||||
password: password,
|
||||
@@ -7804,11 +7848,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* @param relayState - URL Callback after SAML2 Authentication
|
||||
* @returns Promise which resolves: TODO
|
||||
* @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<any> {
|
||||
// TODO: Types
|
||||
public loginWithSAML2(relayState: string): Promise<LoginResponse> {
|
||||
return this.login("m.login.saml2", {
|
||||
relay_state: relayState,
|
||||
});
|
||||
@@ -7848,11 +7892,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* @param token - Login token previously received from homeserver
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Promise which resolves to a LoginResponse object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public loginWithToken(token: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public loginWithToken(token: string): Promise<LoginResponse> {
|
||||
return this.login("m.login.token", {
|
||||
token: token,
|
||||
});
|
||||
@@ -7896,7 +7939,7 @@ 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<{}> {
|
||||
public deactivateAccount(auth?: any, erase?: boolean): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
|
||||
const body: any = {};
|
||||
if (auth) {
|
||||
body.auth = auth;
|
||||
@@ -7917,7 +7960,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: On success, the token response
|
||||
* or UIA auth data.
|
||||
*/
|
||||
public async requestLoginToken(auth?: IAuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
public async requestLoginToken(auth?: AuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
|
||||
// use capabilities to determine which revision of the MSC is being used
|
||||
const capabilities = await this.getCapabilities();
|
||||
// use r1 endpoint if capability is exposed otherwise use old r0 endpoint
|
||||
@@ -8557,7 +8600,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setPassword(authDict: IAuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> {
|
||||
public setPassword(authDict: AuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> {
|
||||
const path = "/account/password";
|
||||
const data = {
|
||||
auth: authDict,
|
||||
@@ -8615,7 +8658,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: result object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public deleteDevice(deviceId: string, auth?: IAuthDict): Promise<IAuthData | {}> {
|
||||
public deleteDevice(deviceId: string, auth?: AuthDict): Promise<{}> {
|
||||
const path = utils.encodeUri("/devices/$device_id", {
|
||||
$device_id: deviceId,
|
||||
});
|
||||
@@ -8637,7 +8680,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: result object
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise<IAuthData | {}> {
|
||||
public deleteMultipleDevices(devices: string[], auth?: AuthDict): Promise<{}> {
|
||||
const body: any = { devices };
|
||||
|
||||
if (auth) {
|
||||
@@ -8922,7 +8965,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Get, "/keys/changes", qps);
|
||||
}
|
||||
|
||||
public uploadDeviceSigningKeys(auth?: IAuthDict, keys?: CrossSigningKeys): Promise<{}> {
|
||||
public uploadDeviceSigningKeys(auth?: AuthDict, keys?: CrossSigningKeys): Promise<{}> {
|
||||
// API returns empty object
|
||||
const data = Object.assign({}, keys);
|
||||
if (auth) Object.assign(data, { auth });
|
||||
@@ -9070,6 +9113,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param msisdnToken - The MSISDN token, as enetered by the user.
|
||||
* @param identityAccessToken - The `access_token` field of the Identity
|
||||
* Server `/account/register` response (see {@link registerWithIdentityServer}).
|
||||
* Some legacy identity servers had no authentication here.
|
||||
*
|
||||
* @returns Promise which resolves: Object, containing success boolean.
|
||||
* @returns Rejects: with an error response.
|
||||
@@ -9079,7 +9123,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
sid: string,
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
identityAccessToken: string,
|
||||
identityAccessToken: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const params = {
|
||||
sid: sid,
|
||||
@@ -9092,7 +9136,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
"/validate/msisdn/submitToken",
|
||||
params,
|
||||
IdentityPrefix.V2,
|
||||
identityAccessToken,
|
||||
identityAccessToken ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9134,8 +9178,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param identityAccessToken - The access token for the identity server.
|
||||
* @returns The hashing information for the identity server.
|
||||
*/
|
||||
public getIdentityHashDetails(identityAccessToken: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public getIdentityHashDetails(identityAccessToken: string): Promise<{
|
||||
/**
|
||||
* The algorithms the server supports. Must contain at least sha256.
|
||||
*/
|
||||
algorithms: string[];
|
||||
/**
|
||||
* The pepper the client MUST use in hashing identifiers,
|
||||
* and MUST supply to the /lookup endpoint when performing lookups.
|
||||
*/
|
||||
lookup_pepper: string;
|
||||
}> {
|
||||
return this.http.idServerRequest(
|
||||
Method.Get,
|
||||
"/hash_details",
|
||||
@@ -9243,8 +9296,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* exists
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public async lookupThreePid(medium: string, address: string, identityAccessToken: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public async lookupThreePid(
|
||||
medium: string,
|
||||
address: string,
|
||||
identityAccessToken: string,
|
||||
): Promise<
|
||||
| {
|
||||
address: string;
|
||||
medium: string;
|
||||
mxid: string;
|
||||
}
|
||||
| {}
|
||||
> {
|
||||
// Note: we're using the V2 API by calling this function, but our
|
||||
// function contract requires a V1 response. We therefore have to
|
||||
// convert it manually.
|
||||
@@ -9280,8 +9343,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: Lookup results from IS.
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public async bulkLookupThreePids(
|
||||
query: [string, string][],
|
||||
identityAccessToken: string,
|
||||
): Promise<{
|
||||
threepids: [medium: string, address: string, mxid: string][];
|
||||
}> {
|
||||
// Note: we're using the V2 API by calling this function, but our
|
||||
// function contract requires a V1 response. We therefore have to
|
||||
// convert it manually.
|
||||
@@ -9319,8 +9386,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves: an object with account info.
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public getIdentityAccount(identityAccessToken: string): Promise<any> {
|
||||
// TODO: Types
|
||||
public getIdentityAccount(identityAccessToken: string): Promise<{ user_id: string }> {
|
||||
return this.http.idServerRequest(Method.Get, "/account", undefined, IdentityPrefix.V2, identityAccessToken);
|
||||
}
|
||||
|
||||
@@ -9413,7 +9479,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Promise which resolves to the result object
|
||||
*/
|
||||
public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> {
|
||||
// TODO: Types
|
||||
const path = utils.encodeUri("/thirdparty/user/$protocol", {
|
||||
$protocol: protocol,
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ import { CryptoApi } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
@@ -79,15 +78,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
*/
|
||||
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
|
||||
+177
-9
@@ -18,13 +18,9 @@ import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
import { UIAuthCallback } from "./interactive-auth";
|
||||
|
||||
/** Types of cross-signing key */
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
|
||||
import { VerificationRequest } from "./crypto-api/verification";
|
||||
import { KeyBackupInfo } from "./crypto-api/keybackup";
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -195,12 +191,99 @@ export interface CryptoApi {
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Bootstrap the secret storage by creating a new secret storage key, add it in the secret storage and
|
||||
* store the cross signing keys in the secret storage.
|
||||
*
|
||||
* - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`.
|
||||
* Only if `setupNewSecretStorage` is set or if there is no AES key in the secret storage
|
||||
* - Store this key in the secret storage and set it as the default key.
|
||||
* - Call `cryptoCallbacks.cacheSecretStorageKey` if provided.
|
||||
* - Store the cross signing keys in the secret storage if
|
||||
* - the cross signing is ready
|
||||
* - a new key was created during the previous step
|
||||
* - or the secret storage already contains the cross signing keys
|
||||
*
|
||||
* @param opts - Options object.
|
||||
*/
|
||||
bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the status of our cross-signing keys.
|
||||
*
|
||||
* @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and whether the private keys are in secret storage.
|
||||
*/
|
||||
getCrossSigningStatus(): Promise<CrossSigningStatus>;
|
||||
|
||||
/**
|
||||
* Create a recovery key (ie, a key suitable for use with server-side secret storage).
|
||||
*
|
||||
* The key can either be based on a user-supplied passphrase, or just created randomly.
|
||||
*
|
||||
* @param password - Optional passphrase string to use to derive the key,
|
||||
* which can later be entered by the user as an alternative to entering the
|
||||
* recovery key itself. If omitted, a key is generated randomly.
|
||||
*
|
||||
* @returns Object including recovery key and server upload parameters.
|
||||
* The private key should be disposed of after displaying to the use.
|
||||
*/
|
||||
createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Device/User verification
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Returns to-device verification requests that are already in progress for the given user id.
|
||||
*
|
||||
* @param userId - the ID of the user to query
|
||||
*
|
||||
* @returns the VerificationRequests that are in progress
|
||||
*/
|
||||
getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[];
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Send a verification request to our other devices.
|
||||
*
|
||||
* This is normally used when the current device is new, and we want to ask another of our devices to cross-sign.
|
||||
*
|
||||
* If an all-devices verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
|
||||
* MatrixClient.
|
||||
*
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
requestOwnUserVerification(): Promise<VerificationRequest>;
|
||||
|
||||
/**
|
||||
* Request an interactive verification with the given device.
|
||||
*
|
||||
* This is normally used on one of our own devices, when the current device is already cross-signed, and we want to
|
||||
* validate another device.
|
||||
*
|
||||
* If a verification for this user/device is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
|
||||
* MatrixClient.
|
||||
*
|
||||
* @param userId - ID of the owner of the device to verify
|
||||
* @param deviceId - ID of the device to verify
|
||||
*
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,8 +384,6 @@ export interface ImportRoomKeysOpts {
|
||||
source?: String; // TODO: Enum (backup, file, ??)
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
|
||||
/**
|
||||
* The result of a call to {@link CryptoApi.getCrossSigningStatus}.
|
||||
*/
|
||||
@@ -324,3 +405,90 @@ export interface CrossSigningStatus {
|
||||
userSigningKey: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crypto callbacks provided by the application
|
||||
*/
|
||||
export interface CryptoCallbacks extends SecretStorageCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
|
||||
/**
|
||||
* Called by {@link CryptoApi#bootstrapSecretStorage}
|
||||
* @param keyId - secret storage key id
|
||||
* @param keyInfo - secret storage key info
|
||||
* @param key - private key to store
|
||||
*/
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
onSecretRequested?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
requestId: string,
|
||||
secretName: string,
|
||||
deviceTrust: DeviceVerificationStatus,
|
||||
) => Promise<string | undefined>;
|
||||
getDehydrationKey?: (
|
||||
keyInfo: SecretStorageKeyDescription,
|
||||
checkFunc: (key: Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
|
||||
*/
|
||||
export interface CreateSecretStorageOpts {
|
||||
/**
|
||||
* Function called to await a secret storage key creation flow.
|
||||
* @returns Promise resolving to an 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.
|
||||
*/
|
||||
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;
|
||||
|
||||
/**
|
||||
* The current key backup object. If passed,
|
||||
* the passphrase and recovery key from this backup will be used.
|
||||
*/
|
||||
keyBackupInfo?: KeyBackupInfo;
|
||||
|
||||
/**
|
||||
* If true, a new key backup version will be
|
||||
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
|
||||
* is supplied.
|
||||
*/
|
||||
setupNewKeyBackup?: boolean;
|
||||
|
||||
/**
|
||||
* Reset even if keys already exist.
|
||||
*/
|
||||
setupNewSecretStorage?: boolean;
|
||||
|
||||
/**
|
||||
* Function called to get the user's
|
||||
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
|
||||
* containing the key, or rejects if the key cannot be obtained.
|
||||
*/
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/** Types of cross-signing key */
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
|
||||
*/
|
||||
export interface GeneratedSecretStorageKey {
|
||||
keyInfo?: AddSecretStorageKeyOpts;
|
||||
/** The raw generated private key. */
|
||||
privateKey: Uint8Array;
|
||||
/** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */
|
||||
encodedPrivateKey?: string;
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
export * from "./crypto-api/keybackup";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
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 { ISigned } from "../@types/signed";
|
||||
|
||||
export interface Curve25519AuthData {
|
||||
public_key: string;
|
||||
private_key_salt?: string;
|
||||
private_key_iterations?: number;
|
||||
private_key_bits?: number;
|
||||
}
|
||||
|
||||
export interface Aes256AuthData {
|
||||
iv: string;
|
||||
mac: string;
|
||||
private_key_salt?: string;
|
||||
private_key_iterations?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra info of a recovery key
|
||||
*/
|
||||
export interface KeyBackupInfo {
|
||||
algorithm: string;
|
||||
auth_data: ISigned & (Curve25519AuthData | Aes256AuthData);
|
||||
count?: number;
|
||||
etag?: string;
|
||||
version?: string; // number contained within
|
||||
}
|
||||
@@ -17,6 +17,208 @@ limitations under the License.
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
|
||||
/**
|
||||
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
|
||||
*/
|
||||
export interface VerificationRequest
|
||||
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap> {
|
||||
/**
|
||||
* Unique ID for this verification request.
|
||||
*
|
||||
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
|
||||
*/
|
||||
get transactionId(): string | undefined;
|
||||
|
||||
/**
|
||||
* For an in-room verification, the ID of the room.
|
||||
*
|
||||
* For to-device verifictions, `undefined`.
|
||||
*/
|
||||
get roomId(): string | undefined;
|
||||
|
||||
/**
|
||||
* True if this request was initiated by the local client.
|
||||
*
|
||||
* For in-room verifications, the initiator is who sent the `m.key.verification.request` event.
|
||||
* For to-device verifications, the initiator is who sent the `m.key.verification.start` event.
|
||||
*/
|
||||
get initiatedByMe(): boolean;
|
||||
|
||||
/** The user id of the other party in this request */
|
||||
get otherUserId(): string;
|
||||
|
||||
/** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */
|
||||
get otherDeviceId(): string | undefined;
|
||||
|
||||
/** True if the other party in this request is one of this user's own devices. */
|
||||
get isSelfVerification(): boolean;
|
||||
|
||||
/** current phase of the request. */
|
||||
get phase(): VerificationPhase;
|
||||
|
||||
/** True if the request has sent its initial event and needs more events to complete
|
||||
* (ie it is in phase `Requested`, `Ready` or `Started`).
|
||||
*/
|
||||
get pending(): boolean;
|
||||
|
||||
/**
|
||||
* True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received
|
||||
* the remote echo which causes a transition to {@link VerificationPhase.Ready}.
|
||||
*/
|
||||
get accepting(): boolean;
|
||||
|
||||
/**
|
||||
* True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received
|
||||
* the remote echo which causes a transition to {@link VerificationPhase.Cancelled}).
|
||||
*/
|
||||
get declining(): boolean;
|
||||
|
||||
/**
|
||||
* The remaining number of ms before the request will be automatically cancelled.
|
||||
*
|
||||
* `null` indicates that there is no timeout
|
||||
*/
|
||||
get timeout(): number | null;
|
||||
|
||||
/** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */
|
||||
get methods(): string[];
|
||||
|
||||
/** the method picked in the .start event */
|
||||
get chosenMethod(): string | null;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* For methods that need to be supported by both ends, use the `methods` property.
|
||||
*
|
||||
* @param method - the method to check
|
||||
* @returns true if the other party said they supported the method
|
||||
*/
|
||||
otherPartySupportsMethod(method: string): boolean;
|
||||
|
||||
/**
|
||||
* Accepts the request, sending a .ready event to the other party
|
||||
*
|
||||
* @returns Promise which resolves when the event has been sent.
|
||||
*/
|
||||
accept(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancels the request, sending a cancellation to the other party
|
||||
*
|
||||
* @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code`
|
||||
* (defaults to `m.user`). **Deprecated**: this parameter is ignored by the Rust cryptography implementation.
|
||||
*
|
||||
* @returns Promise which resolves when the event has been sent.
|
||||
*/
|
||||
cancel(params?: { reason?: string; code?: string }): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a {@link Verifier} to do this verification via a particular method.
|
||||
*
|
||||
* If a verifier has already been created for this request, returns that verifier.
|
||||
*
|
||||
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Crypto.Verifier#verify} on the
|
||||
* returned verifier.
|
||||
*
|
||||
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
* @param targetDevice - details of where to send the request to.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*
|
||||
* @deprecated Use {@link VerificationRequest#startVerification} instead.
|
||||
*/
|
||||
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
|
||||
|
||||
/**
|
||||
* Send an `m.key.verification.start` event to start verification via a particular method.
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*/
|
||||
startVerification(method: string): Promise<Verifier>;
|
||||
|
||||
/**
|
||||
* The verifier which is doing the actual verification, once the method has been established.
|
||||
* Only defined when the `phase` is Started.
|
||||
*/
|
||||
get verifier(): Verifier | undefined;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get cancellationCode(): string | null;
|
||||
|
||||
/**
|
||||
* The id of the user that cancelled the request.
|
||||
*
|
||||
* Only defined when phase is Cancelled
|
||||
*/
|
||||
get cancellingUserId(): string | undefined;
|
||||
}
|
||||
|
||||
/** Events emitted by {@link VerificationRequest}. */
|
||||
export enum VerificationRequestEvent {
|
||||
/**
|
||||
* Fires whenever the state of the request object has changed.
|
||||
*
|
||||
* There is no payload to the event.
|
||||
*/
|
||||
Change = "change",
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener type map for {@link VerificationRequestEvent}s.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type VerificationRequestEventHandlerMap = {
|
||||
[VerificationRequestEvent.Change]: () => void;
|
||||
};
|
||||
|
||||
/** The current phase of a verification request. */
|
||||
export enum VerificationPhase {
|
||||
/** Initial state: no event yet exchanged */
|
||||
Unsent = 1,
|
||||
|
||||
/** An `m.key.verification.request` event has been sent or received */
|
||||
Requested,
|
||||
|
||||
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
|
||||
Ready,
|
||||
|
||||
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
|
||||
Started,
|
||||
|
||||
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
|
||||
Cancelled,
|
||||
|
||||
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
|
||||
Done,
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Verifier` is responsible for performing the verification using a particular method, such as via QR code or SAS
|
||||
* (emojis).
|
||||
@@ -169,3 +371,11 @@ export interface GeneratedSas {
|
||||
* English name.
|
||||
*/
|
||||
export type EmojiMapping = [emoji: string, name: string];
|
||||
|
||||
/**
|
||||
* True if the request is in a state where it can be accepted (ie, that we're in phases {@link VerificationPhase.Unsent}
|
||||
* or {@link VerificationPhase.Requested}, and that we're not in the process of sending a `ready` or `cancel`).
|
||||
*/
|
||||
export function canAcceptVerificationRequest(req: VerificationRequest): boolean {
|
||||
return req.phase < VerificationPhase.Ready && !req.accepting && !req.declining;
|
||||
}
|
||||
|
||||
@@ -194,9 +194,10 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
//
|
||||
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
|
||||
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
|
||||
// more strict and reject events that come from unknown devices.
|
||||
// If the device is unknown then we check that we don't have any pending key-query requests for the sender. If
|
||||
// after that the device is still unknown, then we can only assume that the device logged out and accept it
|
||||
// anyway. Some event handlers, such as secret sharing, may be more strict and reject events that come from
|
||||
// unknown devices.
|
||||
//
|
||||
// This is a defence against the following scenario:
|
||||
//
|
||||
@@ -204,14 +205,26 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
|
||||
// senderkey, but claiming to be from Bob.
|
||||
// * Mallory sends more events using that session, claiming to be from Bob.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
|
||||
// events as verified even though the sender is forged.
|
||||
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those events as
|
||||
// verified even though the sender is forged.
|
||||
//
|
||||
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
|
||||
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
|
||||
let senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
if (senderKeyUser === undefined || senderKeyUser === null) {
|
||||
// Wait for any pending key query fetches for the user to complete before trying the lookup again.
|
||||
try {
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER_CHECK_FAILED", "Could not verify sender identity", {
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
}
|
||||
|
||||
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
}
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
|
||||
real_sender: senderKeyUser,
|
||||
});
|
||||
|
||||
+5
-43
@@ -15,11 +15,14 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import type { AddSecretStorageKeyOpts } from "../secret-storage";
|
||||
|
||||
/* re-exports for backwards compatibility. */
|
||||
// CrossSigningKey is used as a value in `client.ts`, we can't export it as a type
|
||||
export { CrossSigningKey } from "../crypto-api";
|
||||
export type {
|
||||
GeneratedSecretStorageKey as IRecoveryKey,
|
||||
CreateSecretStorageOpts as ICreateSecretStorageOpts,
|
||||
} from "../crypto-api";
|
||||
|
||||
export type {
|
||||
ImportRoomKeyProgressData as IImportOpts,
|
||||
@@ -65,44 +68,3 @@ export interface IEncryptedEventInfo {
|
||||
*/
|
||||
mismatchedSender: boolean;
|
||||
}
|
||||
|
||||
export interface IRecoveryKey {
|
||||
keyInfo?: AddSecretStorageKeyOpts;
|
||||
privateKey: Uint8Array;
|
||||
encodedPrivateKey?: string;
|
||||
}
|
||||
|
||||
export interface ICreateSecretStorageOpts {
|
||||
/**
|
||||
* Function called to await a secret storage key creation flow.
|
||||
* @returns Promise resolving to an 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.
|
||||
*/
|
||||
createSecretStorageKey?: () => Promise<IRecoveryKey>;
|
||||
|
||||
/**
|
||||
* The current key backup object. If passed,
|
||||
* the passphrase and recovery key from this backup will be used.
|
||||
*/
|
||||
keyBackupInfo?: IKeyBackupInfo;
|
||||
|
||||
/**
|
||||
* If true, a new key backup version will be
|
||||
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
|
||||
* is supplied.
|
||||
*/
|
||||
setupNewKeyBackup?: boolean;
|
||||
|
||||
/**
|
||||
* Reset even if keys already exist.
|
||||
*/
|
||||
setupNewSecretStorage?: boolean;
|
||||
|
||||
/**
|
||||
* Function called to get the user's
|
||||
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
|
||||
* containing the key, or rejects if the key cannot be obtained.
|
||||
*/
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
@@ -118,12 +118,21 @@ export class BackupManager {
|
||||
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
|
||||
private sendingBackups: boolean; // Are we currently sending backups?
|
||||
private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
|
||||
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
|
||||
private clientRunning = true;
|
||||
|
||||
public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
|
||||
this.checkedForBackup = false;
|
||||
this.sendingBackups = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the backup manager from backing up keys and allow a clean shutdown.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.clientRunning = false;
|
||||
}
|
||||
|
||||
public get version(): string | undefined {
|
||||
return this.backupInfo && this.backupInfo.version;
|
||||
}
|
||||
@@ -439,6 +448,10 @@ export class BackupManager {
|
||||
// the same time when a new key is sent
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay);
|
||||
if (!this.clientRunning) {
|
||||
logger.debug("Key backup send aborted, client stopped");
|
||||
return;
|
||||
}
|
||||
let numFailures = 0; // number of consecutive failures
|
||||
for (;;) {
|
||||
if (!this.algorithm) {
|
||||
@@ -473,6 +486,11 @@ export class BackupManager {
|
||||
// exponential backoff if we have failures
|
||||
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
|
||||
}
|
||||
|
||||
if (!this.clientRunning) {
|
||||
logger.debug("Key backup send loop aborted, client stopped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.sendingBackups = false;
|
||||
|
||||
+51
-36
@@ -80,7 +80,6 @@ import {
|
||||
AccountDataClient,
|
||||
AddSecretStorageKeyOpts,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
SecretStorageCallbacks,
|
||||
SecretStorageKeyDescription,
|
||||
SecretStorageKeyObject,
|
||||
SecretStorageKeyTuple,
|
||||
@@ -92,12 +91,16 @@ import {
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
ImportRoomKeysOpts,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
|
||||
export type {
|
||||
BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts,
|
||||
CryptoCallbacks as ICryptoCallbacks,
|
||||
} from "../crypto-api";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -127,32 +130,18 @@ export function isCryptoAvailable(): boolean {
|
||||
return Boolean(global.Olm);
|
||||
}
|
||||
|
||||
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// minimum time between attempting to unwedge an Olm session, if we succeeded
|
||||
// in creating a new session
|
||||
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
// minimum time between attempting to unwedge an Olm session, if we failed
|
||||
// to create a new session
|
||||
const FORCE_SESSION_RETRY_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface IInitOpts {
|
||||
exportedOlmDevice?: IExportedDevice;
|
||||
pickleKey?: string;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks extends SecretStorageCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
onSecretRequested?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
requestId: string,
|
||||
secretName: string,
|
||||
deviceTrust: DeviceTrustLevel,
|
||||
) => Promise<string | undefined>;
|
||||
getDehydrationKey?: (
|
||||
keyInfo: SecretStorageKeyDescription,
|
||||
checkFunc: (key: Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoomKey {
|
||||
room_id: string;
|
||||
@@ -235,7 +224,16 @@ export enum CryptoEvent {
|
||||
KeyBackupFailed = "crypto.keyBackupFailed",
|
||||
KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining",
|
||||
KeySignatureUploadFailure = "crypto.keySignatureUploadFailure",
|
||||
/** @deprecated Use `VerificationRequestReceived`. */
|
||||
VerificationRequest = "crypto.verification.request",
|
||||
|
||||
/**
|
||||
* Fires when a key verification request is received.
|
||||
*
|
||||
* The payload is a {@link Crypto.VerificationRequest}.
|
||||
*/
|
||||
VerificationRequestReceived = "crypto.verificationRequestReceived",
|
||||
|
||||
Warning = "crypto.warning",
|
||||
WillUpdateDevices = "crypto.willUpdateDevices",
|
||||
DevicesUpdated = "crypto.devicesUpdated",
|
||||
@@ -297,8 +295,16 @@ export type CryptoEventHandlerMap = {
|
||||
) => void;
|
||||
/**
|
||||
* Fires when a key verification is requested.
|
||||
*
|
||||
* Deprecated: use `CryptoEvent.VerificationRequestReceived`.
|
||||
*/
|
||||
[CryptoEvent.VerificationRequest]: (request: VerificationRequest<any>) => void;
|
||||
|
||||
/**
|
||||
* Fires when a key verification request is received.
|
||||
*/
|
||||
[CryptoEvent.VerificationRequestReceived]: (request: CryptoApiVerificationRequest) => void;
|
||||
|
||||
/**
|
||||
* Fires when the app may wish to warn the user about something related
|
||||
* the end-to-end crypto.
|
||||
@@ -389,7 +395,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// to avoid loading room members as long as possible.
|
||||
private roomDeviceTrackingState: { [roomId: string]: Promise<void> } = {};
|
||||
|
||||
// The timestamp of the last time we forced establishment
|
||||
// The timestamp of the minimum time at which we will retry forcing establishment
|
||||
// of a new session for each device, in milliseconds.
|
||||
// {
|
||||
// userId: {
|
||||
@@ -397,7 +403,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// },
|
||||
// }
|
||||
// Map: user Id → device Id → timestamp
|
||||
private lastNewSessionForced: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault(
|
||||
private forceNewSessionRetryTime: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault(
|
||||
() => new MapWithDefault(() => 0),
|
||||
);
|
||||
|
||||
@@ -1831,6 +1837,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
this.outgoingRoomKeyRequestManager.stop();
|
||||
this.deviceList.stop();
|
||||
this.dehydrationManager.stop();
|
||||
this.backupManager.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2356,6 +2363,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests);
|
||||
}
|
||||
|
||||
/** @deprecated Use `requestOwnUserVerificationToDevice` or `requestDeviceVerification` */
|
||||
public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
|
||||
if (!devices) {
|
||||
devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId));
|
||||
@@ -2368,6 +2376,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests);
|
||||
}
|
||||
|
||||
public requestOwnUserVerification(): Promise<VerificationRequest> {
|
||||
return this.requestVerification(this.userId);
|
||||
}
|
||||
|
||||
public requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest> {
|
||||
return this.requestVerification(userId, [deviceId]);
|
||||
}
|
||||
|
||||
private async requestVerificationWithChannel(
|
||||
userId: string,
|
||||
channel: IVerificationChannel,
|
||||
@@ -3549,6 +3565,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
!request.observeOnly;
|
||||
if (shouldEmit) {
|
||||
this.baseApis.emit(CryptoEvent.VerificationRequest, request);
|
||||
this.baseApis.emit(CryptoEvent.VerificationRequestReceived, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3580,25 +3597,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// check when we last forced a new session with this device: if we've already done so
|
||||
// check when we can force a new session with this device: if we've already done so
|
||||
// recently, don't do it again.
|
||||
const lastNewSessionDevices = this.lastNewSessionForced.getOrCreate(sender);
|
||||
const lastNewSessionForced = lastNewSessionDevices.getOrCreate(deviceKey);
|
||||
if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) {
|
||||
const forceNewSessionRetryTimeDevices = this.forceNewSessionRetryTime.getOrCreate(sender);
|
||||
const forceNewSessionRetryTime = forceNewSessionRetryTimeDevices.getOrCreate(deviceKey);
|
||||
if (forceNewSessionRetryTime > Date.now()) {
|
||||
logger.debug(
|
||||
"New session already forced with device " +
|
||||
sender +
|
||||
":" +
|
||||
deviceKey +
|
||||
" at " +
|
||||
lastNewSessionForced +
|
||||
": not forcing another",
|
||||
`New session already forced with device ${sender}:${deviceKey}: ` +
|
||||
`not forcing another until at least ${new Date(forceNewSessionRetryTime).toUTCString()}`,
|
||||
);
|
||||
await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true);
|
||||
retryDecryption();
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure we don't retry to unwedge too soon even if we fail to create a new session
|
||||
forceNewSessionRetryTimeDevices.set(deviceKey, Date.now() + FORCE_SESSION_RETRY_INTERVAL_MS);
|
||||
|
||||
// establish a new olm session with this device since we're failing to decrypt messages
|
||||
// on a current session.
|
||||
// Note that an undecryptable message from another device could easily be spoofed -
|
||||
@@ -3619,7 +3634,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
const devicesByUser = new Map([[sender, [device]]]);
|
||||
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true);
|
||||
|
||||
lastNewSessionDevices.set(deviceKey, Date.now());
|
||||
forceNewSessionRetryTimeDevices.set(deviceKey, Date.now() + MIN_FORCE_SESSION_INTERVAL_MS);
|
||||
|
||||
// Now send a blank message on that session so the other side knows about it.
|
||||
// (The keyshare request is sent in the clear so that won't do)
|
||||
|
||||
+6
-21
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISigned } from "../@types/signed";
|
||||
import { IEncryptedPayload } from "./aes";
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
@@ -35,27 +34,13 @@ export interface IKeyBackupRoomSessions {
|
||||
[sessionId: string]: IKeyBackupSession;
|
||||
}
|
||||
|
||||
export interface ICurve25519AuthData {
|
||||
public_key: string;
|
||||
private_key_salt?: string;
|
||||
private_key_iterations?: number;
|
||||
private_key_bits?: number;
|
||||
}
|
||||
// Export for backward compatibility
|
||||
export type {
|
||||
Curve25519AuthData as ICurve25519AuthData,
|
||||
Aes256AuthData as IAes256AuthData,
|
||||
KeyBackupInfo as IKeyBackupInfo,
|
||||
} from "../crypto-api/keybackup";
|
||||
|
||||
export interface IAes256AuthData {
|
||||
iv: string;
|
||||
mac: string;
|
||||
private_key_salt?: string;
|
||||
private_key_iterations?: number;
|
||||
}
|
||||
|
||||
export interface IKeyBackupInfo {
|
||||
algorithm: string;
|
||||
auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData);
|
||||
count?: number;
|
||||
etag?: string;
|
||||
version?: string; // number contained within
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export interface IKeyBackupPrepareOpts {
|
||||
|
||||
@@ -18,6 +18,7 @@ limitations under the License.
|
||||
* QR code key verification.
|
||||
*/
|
||||
|
||||
import { crypto } from "../crypto";
|
||||
import { VerificationBase as Base } from "./Base";
|
||||
import { newKeyMismatchError, newUserCancelledError } from "./Error";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
|
||||
@@ -200,7 +201,7 @@ export class QRCodeData {
|
||||
|
||||
private static generateSharedSecret(): string {
|
||||
const secretBytes = new Uint8Array(11);
|
||||
global.crypto.getRandomValues(secretBytes);
|
||||
crypto.getRandomValues(secretBytes);
|
||||
return encodeUnpaddedBase64(secretBytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,17 +52,22 @@ const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short
|
||||
|
||||
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
|
||||
|
||||
// This list was generated from the data in the Matrix specification [1] with the following command:
|
||||
//
|
||||
// jq -r '.[] | " [\"" + .emoji + "\", \"" + (.description|ascii_downcase) + "\"], // " + (.number|tostring)' sas-emoji.json
|
||||
//
|
||||
// [1]: https://github.com/matrix-org/matrix-spec/blob/main/data-definitions/sas-emoji.json
|
||||
const emojiMapping: EmojiMapping[] = [
|
||||
["🐶", "dog"], // 0
|
||||
["🐱", "cat"], // 1
|
||||
["🦁", "lion"], // 2
|
||||
["🐎", "horse"], // 3
|
||||
["🦄", "unicorn"], // 4
|
||||
["🐷", "pig"], // 5
|
||||
["🐘", "elephant"], // 6
|
||||
["🐰", "rabbit"], // 7
|
||||
["🐼", "panda"], // 8
|
||||
["🐓", "rooster"], // 9
|
||||
["🐶", "dog"], // 0
|
||||
["🐱", "cat"], // 1
|
||||
["🦁", "lion"], // 2
|
||||
["🐎", "horse"], // 3
|
||||
["🦄", "unicorn"], // 4
|
||||
["🐷", "pig"], // 5
|
||||
["🐘", "elephant"], // 6
|
||||
["🐰", "rabbit"], // 7
|
||||
["🐼", "panda"], // 8
|
||||
["🐓", "rooster"], // 9
|
||||
["🐧", "penguin"], // 10
|
||||
["🐢", "turtle"], // 11
|
||||
["🐟", "fish"], // 12
|
||||
@@ -83,7 +88,7 @@ const emojiMapping: EmojiMapping[] = [
|
||||
["🍕", "pizza"], // 27
|
||||
["🎂", "cake"], // 28
|
||||
["❤️", "heart"], // 29
|
||||
["🙂", "smiley"], // 30
|
||||
["😀", "smiley"], // 30
|
||||
["🤖", "robot"], // 31
|
||||
["🎩", "hat"], // 32
|
||||
["👓", "glasses"], // 33
|
||||
@@ -113,7 +118,7 @@ const emojiMapping: EmojiMapping[] = [
|
||||
["🎸", "guitar"], // 57
|
||||
["🎺", "trumpet"], // 58
|
||||
["🔔", "bell"], // 59
|
||||
["⚓️", "anchor"], // 60
|
||||
["⚓", "anchor"], // 60
|
||||
["🎧", "headphones"], // 61
|
||||
["📁", "folder"], // 62
|
||||
["📌", "pin"], // 63
|
||||
|
||||
@@ -24,6 +24,17 @@ import { EventType } from "../../../@types/event";
|
||||
import { VerificationBase } from "../Base";
|
||||
import { VerificationMethod } from "../../index";
|
||||
import { TypedEventEmitter } from "../../../models/typed-event-emitter";
|
||||
import {
|
||||
canAcceptVerificationRequest,
|
||||
VerificationPhase as Phase,
|
||||
VerificationRequest as IVerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
VerificationRequestEventHandlerMap,
|
||||
Verifier,
|
||||
} from "../../../crypto-api/verification";
|
||||
|
||||
// backwards-compatibility exports
|
||||
export { VerificationPhase as Phase, VerificationRequestEvent } from "../../../crypto-api/verification";
|
||||
|
||||
// How long after the event's timestamp that the request times out
|
||||
const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -44,15 +55,6 @@ export const CANCEL_TYPE = EVENT_PREFIX + "cancel";
|
||||
export const DONE_TYPE = EVENT_PREFIX + "done";
|
||||
export const READY_TYPE = EVENT_PREFIX + "ready";
|
||||
|
||||
export enum Phase {
|
||||
Unsent = 1,
|
||||
Requested,
|
||||
Ready,
|
||||
Started,
|
||||
Cancelled,
|
||||
Done,
|
||||
}
|
||||
|
||||
// Legacy export fields
|
||||
export const PHASE_UNSENT = Phase.Unsent;
|
||||
export const PHASE_REQUESTED = Phase.Requested;
|
||||
@@ -71,26 +73,17 @@ interface ITransition {
|
||||
event?: MatrixEvent;
|
||||
}
|
||||
|
||||
export enum VerificationRequestEvent {
|
||||
Change = "change",
|
||||
}
|
||||
|
||||
type EventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the state of the request object has changed.
|
||||
*/
|
||||
[VerificationRequestEvent.Change]: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* State machine for verification requests.
|
||||
* Things that differ based on what channel is used to
|
||||
* send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`.
|
||||
*
|
||||
* @deprecated Avoid direct references: instead prefer {@link Crypto.VerificationRequest}.
|
||||
*/
|
||||
export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel> extends TypedEventEmitter<
|
||||
VerificationRequestEvent,
|
||||
EventHandlerMap
|
||||
> {
|
||||
export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel>
|
||||
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap>
|
||||
implements IVerificationRequest
|
||||
{
|
||||
private eventsByUs = new Map<string, MatrixEvent>();
|
||||
private eventsByThem = new Map<string, MatrixEvent>();
|
||||
private _observeOnly = false;
|
||||
@@ -257,7 +250,7 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
}
|
||||
|
||||
public get canAccept(): boolean {
|
||||
return this.phase < PHASE_READY && !this._accepting && !this._declining;
|
||||
return canAcceptVerificationRequest(this);
|
||||
}
|
||||
|
||||
public get accepting(): boolean {
|
||||
@@ -275,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;
|
||||
@@ -285,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 +471,13 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
return this._verifier!;
|
||||
}
|
||||
|
||||
public async startVerification(method: string): Promise<Verifier> {
|
||||
const verifier = this.beginKeyVerification(method);
|
||||
// kick off the verification in the background, but *don't* wait for to complete: we need to return the `Verifier`.
|
||||
verifier.verify();
|
||||
return verifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* sends the initial .request event.
|
||||
* @returns resolves when the event has been sent.
|
||||
|
||||
+104
-74
@@ -21,6 +21,7 @@ import { MatrixClient } from "./client";
|
||||
import { defer, IDeferred } from "./utils";
|
||||
import { MatrixError } from "./http-api";
|
||||
import { UIAResponse } from "./@types/uia";
|
||||
import { UserIdentifier } from "./@types/auth";
|
||||
|
||||
const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
@@ -51,22 +52,25 @@ export interface IStageStatus {
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
|
||||
*/
|
||||
export interface IAuthData {
|
||||
// XXX: many of the fields here (`type`, `available_flows`, `required_stages`, etc) look like they
|
||||
// shouldn't be here. They aren't in the spec and it's unclear what they are supposed to do. Be wary of using them.
|
||||
/**
|
||||
* This is a session identifier that the client must pass back to the home server,
|
||||
* if one is provided, in subsequent attempts to authenticate in the same API call.
|
||||
*/
|
||||
session?: string;
|
||||
type?: string;
|
||||
/**
|
||||
* A list of the stages the client has completed successfully
|
||||
*/
|
||||
completed?: string[];
|
||||
/**
|
||||
* A list of the login flows supported by the server for this API.
|
||||
*/
|
||||
flows?: UIAFlow[];
|
||||
available_flows?: UIAFlow[];
|
||||
stages?: string[];
|
||||
required_stages?: AuthType[];
|
||||
/**
|
||||
* Contains any information that the client will need to know in order to use a given type of authentication.
|
||||
* For each login type presented, that type may be present as a key in this dictionary.
|
||||
* For example, the public part of an OAuth client ID could be given here.
|
||||
*/
|
||||
params?: Record<string, Record<string, any>>;
|
||||
data?: Record<string, string>;
|
||||
errcode?: string;
|
||||
error?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
@@ -85,30 +89,62 @@ export enum AuthType {
|
||||
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
|
||||
}
|
||||
|
||||
/**
|
||||
* https://spec.matrix.org/v1.7/client-server-api/#password-based
|
||||
*/
|
||||
type PasswordDict = {
|
||||
type: AuthType.Password;
|
||||
identifier: UserIdentifier;
|
||||
password: string;
|
||||
session: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://spec.matrix.org/v1.7/client-server-api/#google-recaptcha
|
||||
*/
|
||||
type RecaptchaDict = {
|
||||
type: AuthType.Recaptcha;
|
||||
response: string;
|
||||
session: string;
|
||||
};
|
||||
|
||||
interface ThreepidCreds {
|
||||
sid: string;
|
||||
client_secret: string;
|
||||
id_server: string;
|
||||
id_access_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://spec.matrix.org/v1.7/client-server-api/#email-based-identity--homeserver
|
||||
*/
|
||||
type EmailIdentityDict = {
|
||||
type: AuthType.Email;
|
||||
threepid_creds: ThreepidCreds;
|
||||
/**
|
||||
* @deprecated in favour of `threepid_creds` - kept for backwards compatibility
|
||||
*/
|
||||
threepidCreds?: ThreepidCreds;
|
||||
session: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The parameters which are submitted as the `auth` dict in a UIA request
|
||||
*
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
export interface IAuthDict {
|
||||
// [key: string]: any;
|
||||
type?: string;
|
||||
session?: string;
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user?: string;
|
||||
identifier?: any;
|
||||
password?: string;
|
||||
response?: string;
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
// eslint-disable-next-line camelcase
|
||||
threepid_creds?: any;
|
||||
threepidCreds?: any;
|
||||
// For m.login.registration_token type
|
||||
token?: string;
|
||||
}
|
||||
export type AuthDict =
|
||||
| PasswordDict
|
||||
| RecaptchaDict
|
||||
| EmailIdentityDict
|
||||
| { type: Exclude<string, AuthType>; [key: string]: any }
|
||||
| {};
|
||||
|
||||
/**
|
||||
* Backwards compatible export
|
||||
* @deprecated in favour of AuthDict
|
||||
*/
|
||||
export type IAuthDict = AuthDict;
|
||||
|
||||
export class NoAuthFlowFoundError extends Error {
|
||||
public name = "NoAuthFlowFoundError";
|
||||
@@ -129,7 +165,7 @@ export class NoAuthFlowFoundError extends Error {
|
||||
*/
|
||||
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
|
||||
|
||||
interface IOpts {
|
||||
interface IOpts<T> {
|
||||
/**
|
||||
* A matrix client to use for the auth process
|
||||
*/
|
||||
@@ -170,7 +206,7 @@ interface IOpts {
|
||||
* The busyChanged callback should be used instead of the background flag.
|
||||
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
|
||||
*/
|
||||
doRequest(auth: IAuthDict | null, background: boolean): Promise<IAuthData>;
|
||||
doRequest(auth: AuthDict | null, background: boolean): Promise<T>;
|
||||
/**
|
||||
* Called when the status of the UI auth changes,
|
||||
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
|
||||
@@ -215,21 +251,23 @@ interface IOpts {
|
||||
* submitAuthDict.
|
||||
*
|
||||
* @param opts - options object
|
||||
* @typeParam T - the return type of the request when it is successful
|
||||
*/
|
||||
export class InteractiveAuth {
|
||||
export class InteractiveAuth<T> {
|
||||
private readonly matrixClient: MatrixClient;
|
||||
private readonly inputs: IInputs;
|
||||
private readonly clientSecret: string;
|
||||
private readonly requestCallback: IOpts["doRequest"];
|
||||
private readonly busyChangedCallback?: IOpts["busyChanged"];
|
||||
private readonly stateUpdatedCallback: IOpts["stateUpdated"];
|
||||
private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
|
||||
private readonly requestCallback: IOpts<T>["doRequest"];
|
||||
private readonly busyChangedCallback?: IOpts<T>["busyChanged"];
|
||||
private readonly stateUpdatedCallback: IOpts<T>["stateUpdated"];
|
||||
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;
|
||||
private emailSid?: string;
|
||||
private requestingEmailToken = false;
|
||||
private attemptAuthDeferred: IDeferred<IAuthData> | null = null;
|
||||
private attemptAuthDeferred: IDeferred<T> | null = null;
|
||||
private chosenFlow: UIAFlow | null = null;
|
||||
private currentStage: string | null = null;
|
||||
|
||||
@@ -239,9 +277,9 @@ export class InteractiveAuth {
|
||||
// the promise the will resolve/reject when it completes
|
||||
private submitPromise: Promise<void> | null = null;
|
||||
|
||||
public constructor(opts: IOpts) {
|
||||
public constructor(opts: IOpts<T>) {
|
||||
this.matrixClient = opts.matrixClient;
|
||||
this.data = opts.authData || {};
|
||||
this.data = opts.authData || { flows: [] };
|
||||
this.requestCallback = opts.doRequest;
|
||||
this.busyChangedCallback = opts.busyChanged;
|
||||
// startAuthStage included for backwards compat
|
||||
@@ -262,7 +300,7 @@ export class InteractiveAuth {
|
||||
* or rejects with the error on failure. Rejects with NoAuthFlowFoundError if
|
||||
* no suitable authentication flow can be found
|
||||
*/
|
||||
public attemptAuth(): Promise<IAuthData> {
|
||||
public async attemptAuth(): Promise<T> {
|
||||
// This promise will be quite long-lived and will resolve when the
|
||||
// request is authenticated and completes successfully.
|
||||
this.attemptAuthDeferred = defer();
|
||||
@@ -270,10 +308,10 @@ export class InteractiveAuth {
|
||||
const promise = this.attemptAuthDeferred.promise;
|
||||
|
||||
// if we have no flows, try a request to acquire the flows
|
||||
if (!this.data?.flows) {
|
||||
if (!(this.data as IAuthData)?.flows?.length) {
|
||||
this.busyChangedCallback?.(true);
|
||||
// use the existing sessionId, if one is present.
|
||||
const auth = this.data.session ? { session: this.data.session } : null;
|
||||
const auth = (this.data as IAuthData).session ? { session: (this.data as IAuthData).session } : null;
|
||||
this.doRequest(auth).finally(() => {
|
||||
this.busyChangedCallback?.(false);
|
||||
});
|
||||
@@ -290,7 +328,7 @@ export class InteractiveAuth {
|
||||
* be resolved.
|
||||
*/
|
||||
public async poll(): Promise<void> {
|
||||
if (!this.data.session) return;
|
||||
if (!(this.data as IAuthData).session) return;
|
||||
// likewise don't poll if there is no auth session in progress
|
||||
if (!this.attemptAuthDeferred) return;
|
||||
// if we currently have a request in flight, there's no point making
|
||||
@@ -330,7 +368,7 @@ export class InteractiveAuth {
|
||||
* @returns session id
|
||||
*/
|
||||
public getSessionId(): string | undefined {
|
||||
return this.data?.session;
|
||||
return (this.data as IAuthData)?.session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,7 +388,7 @@ export class InteractiveAuth {
|
||||
* @returns any parameters from the server for this stage
|
||||
*/
|
||||
public getStageParams(loginType: string): Record<string, any> | undefined {
|
||||
return this.data.params?.[loginType];
|
||||
return (this.data as IAuthData)?.params?.[loginType];
|
||||
}
|
||||
|
||||
public getChosenFlow(): UIAFlow | null {
|
||||
@@ -391,9 +429,9 @@ export class InteractiveAuth {
|
||||
|
||||
// use the sessionid from the last request, if one is present.
|
||||
let auth: IAuthDict;
|
||||
if (this.data.session) {
|
||||
if ((this.data as IAuthData)?.session) {
|
||||
auth = {
|
||||
session: this.data.session,
|
||||
session: (this.data as IAuthData).session,
|
||||
};
|
||||
Object.assign(auth, authData);
|
||||
} else {
|
||||
@@ -451,7 +489,7 @@ export class InteractiveAuth {
|
||||
this.inputs.emailAddress!,
|
||||
this.clientSecret,
|
||||
this.emailAttempt++,
|
||||
this.data.session!,
|
||||
(this.data as IAuthData).session!,
|
||||
);
|
||||
this.emailSid = requestTokenResult.sid;
|
||||
logger.trace("Email token request succeeded");
|
||||
@@ -480,10 +518,12 @@ export class InteractiveAuth {
|
||||
this.attemptAuthDeferred!.resolve(result);
|
||||
this.attemptAuthDeferred = null;
|
||||
} catch (error) {
|
||||
const matrixError = error instanceof MatrixError ? error : null;
|
||||
|
||||
// sometimes UI auth errors don't come with flows
|
||||
const errorFlows = (<MatrixError>error).data?.flows ?? null;
|
||||
const haveFlows = this.data.flows || Boolean(errorFlows);
|
||||
if ((<MatrixError>error).httpStatus !== 401 || !(<MatrixError>error).data || !haveFlows) {
|
||||
const errorFlows = matrixError?.data?.flows ?? null;
|
||||
const haveFlows = (this.data as IAuthData)?.flows || Boolean(errorFlows);
|
||||
if (!matrixError || matrixError.httpStatus !== 401 || !matrixError.data || !haveFlows) {
|
||||
// doesn't look like an interactive-auth failure.
|
||||
if (!background) {
|
||||
this.attemptAuthDeferred?.reject(error);
|
||||
@@ -494,24 +534,22 @@ export class InteractiveAuth {
|
||||
logger.log("Background poll request failed doing UI auth: ignoring", error);
|
||||
}
|
||||
}
|
||||
if (!(<MatrixError>error).data) {
|
||||
(<MatrixError>error).data = {};
|
||||
if (matrixError && !matrixError.data) {
|
||||
matrixError.data = {};
|
||||
}
|
||||
// if the error didn't come with flows, completed flows or session ID,
|
||||
// copy over the ones we have. Synapse sometimes sends responses without
|
||||
// any UI auth data (eg. when polling for email validation, if the email
|
||||
// has not yet been validated). This appears to be a Synapse bug, which
|
||||
// we workaround here.
|
||||
if (
|
||||
!(<MatrixError>error).data.flows &&
|
||||
!(<MatrixError>error).data.completed &&
|
||||
!(<MatrixError>error).data.session
|
||||
) {
|
||||
(<MatrixError>error).data.flows = this.data.flows;
|
||||
(<MatrixError>error).data.completed = this.data.completed;
|
||||
(<MatrixError>error).data.session = this.data.session;
|
||||
if (matrixError && !matrixError.data.flows && !matrixError.data.completed && !matrixError.data.session) {
|
||||
matrixError.data.flows = (this.data as IAuthData).flows;
|
||||
matrixError.data.completed = (this.data as IAuthData).completed;
|
||||
matrixError.data.session = (this.data as IAuthData).session;
|
||||
}
|
||||
if (matrixError) {
|
||||
this.data = matrixError.data as IAuthData;
|
||||
}
|
||||
this.data = (<MatrixError>error).data;
|
||||
try {
|
||||
this.startNextAuthStage();
|
||||
} catch (e) {
|
||||
@@ -563,14 +601,6 @@ export class InteractiveAuth {
|
||||
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 } : {});
|
||||
}
|
||||
|
||||
@@ -618,7 +648,7 @@ export class InteractiveAuth {
|
||||
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
|
||||
*/
|
||||
private chooseFlow(): UIAFlow {
|
||||
const flows = this.data.flows || [];
|
||||
const flows = (this.data as IAuthData)?.flows || [];
|
||||
|
||||
// we've been given an email or we've already done an email part
|
||||
const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
|
||||
@@ -659,7 +689,7 @@ export class InteractiveAuth {
|
||||
* @returns login type
|
||||
*/
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | string | undefined {
|
||||
const completed = this.data.completed || [];
|
||||
const completed = (this.data as IAuthData)?.completed || [];
|
||||
return flow.stages.find((stageType) => !completed.includes(stageType));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -38,7 +38,11 @@ log.methodFactory = function (methodName, logLevel, loggerName) {
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-invalid-this */
|
||||
const supportedByConsole =
|
||||
methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info";
|
||||
methodName === "error" ||
|
||||
methodName === "warn" ||
|
||||
methodName === "trace" ||
|
||||
methodName === "info" ||
|
||||
methodName === "debug";
|
||||
/* eslint-disable no-console */
|
||||
if (supportedByConsole) {
|
||||
return console[methodName](...args);
|
||||
|
||||
@@ -721,13 +721,17 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
"in timelineSet(threadId=${this.thread?.id})`);
|
||||
}
|
||||
|
||||
const eventId = event.getId()!;
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
|
||||
// threaded message should not be in the main timeline).
|
||||
//
|
||||
// We can only run this check for timelines with a `room` because `canContain`
|
||||
// requires it
|
||||
if (this.room && !this.canContain(event)) {
|
||||
let eventDebugString = `event=${event.getId()}`;
|
||||
let eventDebugString = `event=${eventId}`;
|
||||
if (event.threadRootId) {
|
||||
eventDebugString += `(belongs to thread=${event.threadRootId})`;
|
||||
}
|
||||
@@ -738,7 +742,6 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = event.getId()!;
|
||||
timeline.addEvent(event, {
|
||||
toStartOfTimeline,
|
||||
roomState,
|
||||
@@ -746,9 +749,6 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
});
|
||||
this._eventIdToTimeline.set(eventId, timeline);
|
||||
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
|
||||
@@ -778,22 +778,26 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
*/
|
||||
public insertEventIntoTimeline(event: MatrixEvent, timeline: EventTimeline, roomState: RoomState): void {
|
||||
if (timeline.getTimelineSet() !== this) {
|
||||
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
|
||||
throw new Error(`EventTimelineSet.insertEventIntoTimeline: Timeline=${timeline.toString()} does not belong " +
|
||||
"in timelineSet(threadId=${this.thread?.id})`);
|
||||
}
|
||||
|
||||
const eventId = event.getId()!;
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
|
||||
// threaded message should not be in the main timeline).
|
||||
//
|
||||
// We can only run this check for timelines with a `room` because `canContain`
|
||||
// requires it
|
||||
if (this.room && !this.canContain(event)) {
|
||||
let eventDebugString = `event=${event.getId()}`;
|
||||
let eventDebugString = `event=${eventId}`;
|
||||
if (event.threadRootId) {
|
||||
eventDebugString += `(belongs to thread=${event.threadRootId})`;
|
||||
}
|
||||
logger.warn(
|
||||
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
|
||||
`EventTimelineSet.insertEventIntoTimeline: Ignoring ${eventDebugString} that does not belong ` +
|
||||
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
|
||||
);
|
||||
return;
|
||||
@@ -830,13 +834,9 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
// If we got to the end of the loop, insertIndex points at the end of
|
||||
// the list.
|
||||
|
||||
const eventId = event.getId()!;
|
||||
timeline.insertEvent(event, insertIndex, roomState);
|
||||
this._eventIdToTimeline.set(eventId, timeline);
|
||||
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
liveEvent: timeline == this.liveTimeline,
|
||||
|
||||
+29
-10
@@ -29,12 +29,13 @@ import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
ToDeviceMessageId,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { Crypto } from "../crypto";
|
||||
import { deepSortedObjectEntries, internaliseString } from "../utils";
|
||||
import { RoomMember } from "./room-member";
|
||||
import { Thread, ThreadEvent, ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, ThreadEventHandlerMap } from "./thread";
|
||||
import { IActionsObject } from "../pushprocessor";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { MatrixError } from "../http-api";
|
||||
@@ -62,10 +63,12 @@ export interface IContent {
|
||||
type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>;
|
||||
|
||||
export interface IUnsigned {
|
||||
[key: string]: any;
|
||||
"age"?: number;
|
||||
"prev_sender"?: string;
|
||||
"prev_content"?: IContent;
|
||||
"redacted_because"?: IEvent;
|
||||
"replaces_state"?: string;
|
||||
"transaction_id"?: string;
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
@@ -517,16 +520,15 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* ```
|
||||
*/
|
||||
public getDetails(): string {
|
||||
let details = `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()}`;
|
||||
const room = this.getRoomId();
|
||||
if (room) {
|
||||
details += ` room=${room}`;
|
||||
// in-room event
|
||||
return `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()} room=${room} ts=${this.getDate()?.toISOString()}`;
|
||||
} else {
|
||||
// to-device event
|
||||
const msgid = this.getContent()[ToDeviceMessageId];
|
||||
return `msgid=${msgid} type=${this.getWireType()} sender=${this.getSender()}`;
|
||||
}
|
||||
const date = this.getDate();
|
||||
if (date) {
|
||||
details += ` ts=${date.toISOString()}`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,6 +578,10 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* Get the event ID of the thread head
|
||||
*/
|
||||
public get threadRootId(): string | undefined {
|
||||
// don't allow state events to be threaded as per the spec
|
||||
if (this.isState()) {
|
||||
return undefined;
|
||||
}
|
||||
const relatesTo = this.getWireContent()?.["m.relates_to"];
|
||||
if (relatesTo?.rel_type === THREAD_RELATION_TYPE.name) {
|
||||
return relatesTo.event_id;
|
||||
@@ -597,6 +603,11 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* A helper to check if an event is a thread's head or not
|
||||
*/
|
||||
public get isThreadRoot(): boolean {
|
||||
// don't allow state events to be threaded as per the spec
|
||||
if (this.isState()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const threadDetails = this.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
|
||||
|
||||
// Bundled relationships only returned when the sync response is limited
|
||||
@@ -1365,8 +1376,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// Relation info is lifted out of the encrypted content when sent to
|
||||
// encrypted rooms, so we have to check `getWireContent` for this.
|
||||
const relation = this.getWireContent()?.["m.relates_to"];
|
||||
if (this.isState() && relation?.rel_type === RelationType.Replace) {
|
||||
// State events cannot be m.replace relations
|
||||
if (
|
||||
this.isState() &&
|
||||
!!relation?.rel_type &&
|
||||
([RelationType.Replace, RelationType.Thread] as string[]).includes(relation.rel_type)
|
||||
) {
|
||||
// State events cannot be m.replace or m.thread relations
|
||||
return false;
|
||||
}
|
||||
return !!(relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true));
|
||||
@@ -1618,6 +1633,10 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @param thread - the thread
|
||||
*/
|
||||
public setThread(thread?: Thread): void {
|
||||
// don't allow state events to be threaded as per the spec
|
||||
if (this.isState()) {
|
||||
return;
|
||||
}
|
||||
if (this.thread) {
|
||||
this.reEmitter.stopReEmitting(this.thread, [ThreadEvent.Update]);
|
||||
}
|
||||
|
||||
@@ -309,4 +309,13 @@ export abstract class ReadReceipt<
|
||||
// We don't know if the user has read it, so assume not.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent unthreaded receipt for a given user
|
||||
* @param userId - the MxID of the User
|
||||
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
|
||||
* or a user chooses to use private read receipts (or we have simply not received
|
||||
* a receipt from this user yet).
|
||||
*/
|
||||
public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined;
|
||||
}
|
||||
|
||||
@@ -2008,6 +2008,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
const poll = new Poll(event, this.client, this);
|
||||
this.polls.set(event.getId()!, poll);
|
||||
this.emit(PollEvent.New, poll);
|
||||
|
||||
// remove the poll when redacted
|
||||
event.once(MatrixEventEvent.BeforeRedaction, (redactedEvent: MatrixEvent) => {
|
||||
this.polls.delete(redactedEvent.getId()!);
|
||||
});
|
||||
} catch {}
|
||||
// poll creation can fail for malformed poll start events
|
||||
return;
|
||||
|
||||
+58
-10
@@ -27,7 +27,7 @@ import { RoomState } from "./room-state";
|
||||
import { ServerControlledNamespacedValue } from "../NamespacedValue";
|
||||
import { logger } from "../logger";
|
||||
import { ReadReceipt } from "./read-receipt";
|
||||
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
|
||||
import { CachedReceiptStructure, Receipt, ReceiptType } from "../@types/read_receipts";
|
||||
import { Feature, ServerSupport } from "../feature";
|
||||
|
||||
export enum ThreadEvent {
|
||||
@@ -98,6 +98,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
public readonly room: Room;
|
||||
public readonly client: MatrixClient;
|
||||
private readonly pendingEventOrdering: PendingEventOrdering;
|
||||
private processRootEventPromise?: Promise<void>;
|
||||
|
||||
public initialEventsFetched = !Thread.hasServerSideSupport;
|
||||
/**
|
||||
@@ -134,6 +135,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
this.room.on(RoomEvent.Redaction, this.onRedaction);
|
||||
this.room.on(RoomEvent.LocalEchoUpdated, this.onLocalEcho);
|
||||
this.room.on(RoomEvent.TimelineReset, this.onTimelineReset);
|
||||
this.timelineSet.on(RoomEvent.Timeline, this.onTimelineEvent);
|
||||
|
||||
this.processReceipts(opts.receipts);
|
||||
@@ -144,6 +146,12 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
this.setEventMetadata(this.rootEvent);
|
||||
}
|
||||
|
||||
private onTimelineReset = async (): Promise<void> => {
|
||||
// We hit a gappy sync, ask the server for an update
|
||||
await this.processRootEventPromise;
|
||||
this.processRootEventPromise = undefined;
|
||||
};
|
||||
|
||||
private async fetchRootEvent(): Promise<void> {
|
||||
this.rootEvent = this.room.findEventById(this.id);
|
||||
// If the rootEvent does not exist in the local stores, then fetch it from the server.
|
||||
@@ -197,6 +205,11 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
this._currentUserParticipated = false;
|
||||
this.emit(ThreadEvent.Delete, this);
|
||||
} else {
|
||||
if (this.lastEvent?.getId() === event.getAssociatedId()) {
|
||||
// XXX: If our last event got redacted we query the server for the last event once again
|
||||
await this.processRootEventPromise;
|
||||
this.processRootEventPromise = undefined;
|
||||
}
|
||||
await this.updateThreadMetadata();
|
||||
}
|
||||
};
|
||||
@@ -212,6 +225,9 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) {
|
||||
room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
|
||||
}
|
||||
if (event.getId() !== this.id && event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
this.replyCount++;
|
||||
}
|
||||
}
|
||||
this.onEcho(event, toStartOfTimeline ?? false);
|
||||
};
|
||||
@@ -245,6 +261,8 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
await this.updateThreadMetadata();
|
||||
if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; // don't send a new reply event for reactions or edits
|
||||
if (toStartOfTimeline) return; // ignore messages added to the start of the timeline
|
||||
// Clear the lastEvent and instead start tracking locally using lastReply
|
||||
this.lastEvent = undefined;
|
||||
this.emit(ThreadEvent.NewReply, this, event);
|
||||
};
|
||||
|
||||
@@ -308,6 +326,11 @@ 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;
|
||||
|
||||
@@ -351,10 +374,14 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
return;
|
||||
}
|
||||
|
||||
// If no thread support exists we want to count all thread relation
|
||||
// added as a reply. We can't rely on the bundled relationships count
|
||||
if ((!Thread.hasServerSideSupport || !this.rootEvent) && event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
this.replyCount++;
|
||||
if (
|
||||
event.getId() !== this.id &&
|
||||
event.isRelation(THREAD_RELATION_TYPE.name) &&
|
||||
!toStartOfTimeline &&
|
||||
isNewestReply
|
||||
) {
|
||||
// Clear the last event as we have the latest end of the timeline
|
||||
this.lastEvent = undefined;
|
||||
}
|
||||
|
||||
if (emit) {
|
||||
@@ -475,18 +502,26 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
}
|
||||
}
|
||||
|
||||
private async updateThreadMetadata(): Promise<void> {
|
||||
this.updatePendingReplyCount();
|
||||
|
||||
private async updateThreadFromRootEvent(): Promise<void> {
|
||||
if (Thread.hasServerSideSupport) {
|
||||
// Ensure we show *something* as soon as possible, we'll update it as soon as we get better data, but we
|
||||
// don't want the thread preview to be empty if we can avoid it
|
||||
if (!this.initialEventsFetched) {
|
||||
if (!this.initialEventsFetched && !this.lastEvent) {
|
||||
await this.processRootEvent();
|
||||
}
|
||||
await this.fetchRootEvent();
|
||||
}
|
||||
await this.processRootEvent();
|
||||
}
|
||||
|
||||
private async updateThreadMetadata(): Promise<void> {
|
||||
this.updatePendingReplyCount();
|
||||
|
||||
if (!this.processRootEventPromise) {
|
||||
// We only want to do this once otherwise we end up rolling back to the last unsigned summary we have for the thread
|
||||
this.processRootEventPromise = this.updateThreadFromRootEvent();
|
||||
}
|
||||
await this.processRootEventPromise;
|
||||
|
||||
if (!this.initialEventsFetched) {
|
||||
this.initialEventsFetched = true;
|
||||
@@ -572,7 +607,9 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
/**
|
||||
* Return last reply to the thread, if known.
|
||||
*/
|
||||
public lastReply(matches: (ev: MatrixEvent) => boolean = (): boolean => true): MatrixEvent | null {
|
||||
public lastReply(
|
||||
matches: (ev: MatrixEvent) => boolean = (ev): boolean => ev.isRelation(RelationType.Thread),
|
||||
): MatrixEvent | null {
|
||||
for (let i = this.timeline.length - 1; i >= 0; i--) {
|
||||
const event = this.timeline[i];
|
||||
if (matches(event)) {
|
||||
@@ -711,6 +748,17 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
public setUnread(type: NotificationCountType, count: number): void {
|
||||
return this.room.setThreadUnreadNotificationCount(this.id, type, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent unthreaded receipt for a given user
|
||||
* @param userId - the MxID of the User
|
||||
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
|
||||
* or a user chooses to use private read receipts (or we have simply not received
|
||||
* a receipt from this user yet).
|
||||
*/
|
||||
public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined {
|
||||
return this.room.getLastUnthreadedReceiptFor(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
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 { Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
|
||||
import { logger } from "../logger";
|
||||
import { randomString } from "../randomstring";
|
||||
import { OidcError } from "./error";
|
||||
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.
|
||||
*
|
||||
* See https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters.
|
||||
*/
|
||||
export type AuthorizationParams = {
|
||||
state: string;
|
||||
scope: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
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}`;
|
||||
};
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc7636
|
||||
const generateCodeChallenge = async (codeVerifier: string): Promise<string> => {
|
||||
if (!subtleCrypto) {
|
||||
// @TODO(kerrya) should this be allowed? configurable?
|
||||
logger.warn("A secure context is required to generate code challenge. Using plain text code challenge");
|
||||
return codeVerifier;
|
||||
}
|
||||
const utf8 = new TextEncoder().encode(codeVerifier);
|
||||
|
||||
const digest = await subtleCrypto.digest("SHA-256", utf8);
|
||||
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate authorization params to pass to {@link generateAuthorizationUrl}.
|
||||
*
|
||||
* Used as part of an authorization code OIDC flow: see https://openid.net/specs/openid-connect-basic-1_0.html#CodeFlow.
|
||||
*
|
||||
* @param redirectUri - absolute url for OP to redirect to after authorization
|
||||
* @returns AuthorizationParams
|
||||
*/
|
||||
export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: string }): AuthorizationParams => ({
|
||||
scope: generateScope(),
|
||||
redirectUri,
|
||||
state: randomString(8),
|
||||
nonce: randomString(8),
|
||||
codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters
|
||||
});
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @param clientId - id of this client as registered with the OP
|
||||
* @param authorizationParams - params to be used in the url
|
||||
* @returns a Promise with the url as a string
|
||||
*/
|
||||
export const generateAuthorizationUrl = async (
|
||||
authorizationUrl: string,
|
||||
clientId: string,
|
||||
{ scope, redirectUri, state, nonce, codeVerifier }: AuthorizationParams,
|
||||
): Promise<string> => {
|
||||
const url = new URL(authorizationUrl);
|
||||
url.searchParams.append("response_mode", "query");
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("redirect_uri", redirectUri);
|
||||
url.searchParams.append("client_id", clientId);
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("scope", scope);
|
||||
url.searchParams.append("nonce", nonce);
|
||||
|
||||
url.searchParams.append("code_challenge_method", "S256");
|
||||
url.searchParams.append("code_challenge", await generateCodeChallenge(codeVerifier));
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @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 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,
|
||||
});
|
||||
|
||||
return request.url;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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"
|
||||
* Later, when used in auth headers it is case sensitive and must be Bearer
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4
|
||||
*
|
||||
* @param response - validated token response
|
||||
* @returns response with token_type set to 'Bearer'
|
||||
*/
|
||||
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
|
||||
* request to the Token Endpoint, to obtain the access token, refresh token, etc.
|
||||
*
|
||||
* @param code - authorization code as returned by OP during authorization
|
||||
* @param storedAuthorizationParams - stored params from start of oidc login flow
|
||||
* @returns valid bearer token response
|
||||
* @throws when request fails, or returned token response is invalid
|
||||
*/
|
||||
export const completeAuthorizationCodeGrant = async (
|
||||
code: string,
|
||||
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);
|
||||
|
||||
// set oidc-client to use our logger
|
||||
Log.setLogger(logger);
|
||||
try {
|
||||
const response = new SigninResponse(reconstructedUrl.searchParams);
|
||||
|
||||
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export enum OidcError {
|
||||
NotSupported = "OIDC authentication not supported",
|
||||
Misconfigured = "OIDC is misconfigured",
|
||||
General = "Something went wrong with OIDC discovery",
|
||||
OpSupport = "Configured OIDC OP does not support required functions",
|
||||
DynamicRegistrationNotSupported = "Dynamic registration not supported",
|
||||
DynamicRegistrationFailed = "Dynamic registration failed",
|
||||
DynamicRegistrationInvalid = "Dynamic registration invalid response",
|
||||
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.",
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
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 { IDelegatedAuthConfig } from "../client";
|
||||
import { OidcError } from "./error";
|
||||
import { Method } from "../http-api";
|
||||
import { logger } from "../logger";
|
||||
import { ValidatedIssuerConfig } from "./validate";
|
||||
|
||||
/**
|
||||
* Client metadata passed to registration endpoint
|
||||
*/
|
||||
export type OidcRegistrationClientMetadata = {
|
||||
clientName: string;
|
||||
clientUri: string;
|
||||
redirectUris: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Make the client registration request
|
||||
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
|
||||
* @param clientMetadata - registration metadata
|
||||
* @returns resolves to the registered client id when registration is successful
|
||||
* @throws when registration request fails, or response is invalid
|
||||
*/
|
||||
const doRegistration = async (
|
||||
registrationEndpoint: string,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
const metadata = {
|
||||
client_name: clientMetadata.clientName,
|
||||
client_uri: clientMetadata.clientUri,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
redirect_uris: clientMetadata.redirectUris,
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
};
|
||||
const headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(registrationEndpoint, {
|
||||
method: Method.Post,
|
||||
headers,
|
||||
body: JSON.stringify(metadata),
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(OidcError.DynamicRegistrationFailed);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
const clientId = body["client_id"];
|
||||
if (!clientId || typeof clientId !== "string") {
|
||||
throw new Error(OidcError.DynamicRegistrationInvalid);
|
||||
}
|
||||
|
||||
return clientId;
|
||||
} catch (error) {
|
||||
if (Object.values(OidcError).includes((error as Error).message as OidcError)) {
|
||||
throw error;
|
||||
} else {
|
||||
logger.error("Dynamic registration request failed", error);
|
||||
throw new Error(OidcError.DynamicRegistrationFailed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from ValidatedServerConfig
|
||||
* @param clientName - Client name to register with the OP, eg 'Element'
|
||||
* @param baseUrl - URL of the home page of the Client, eg 'https://app.element.io/'
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
* @throws when registration is not supported, on failed request or invalid response
|
||||
*/
|
||||
export const registerOidcClient = async (
|
||||
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig,
|
||||
clientName: string,
|
||||
baseUrl: string,
|
||||
): Promise<string> => {
|
||||
const clientMetadata = {
|
||||
clientName,
|
||||
clientUri: baseUrl,
|
||||
redirectUris: [baseUrl],
|
||||
};
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
|
||||
|
||||
return clientId;
|
||||
};
|
||||
+209
-19
@@ -14,15 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import jwtDecode from "jwt-decode";
|
||||
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
|
||||
|
||||
export enum OidcDiscoveryError {
|
||||
NotSupported = "OIDC authentication not supported",
|
||||
Misconfigured = "OIDC is misconfigured",
|
||||
General = "Something went wrong with OIDC discovery",
|
||||
OpSupport = "Configured OIDC OP does not support required functions",
|
||||
}
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import { OidcError } from "./error";
|
||||
|
||||
/**
|
||||
* re-export for backwards compatibility
|
||||
* @deprecated use OidcError
|
||||
*/
|
||||
export { OidcError as OidcDiscoveryError };
|
||||
|
||||
export type ValidatedIssuerConfig = {
|
||||
authorizationEndpoint: string;
|
||||
@@ -37,11 +40,9 @@ 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(OidcDiscoveryError.NotSupported);
|
||||
throw new Error(OidcError.NotSupported);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -54,21 +55,21 @@ export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): ID
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(OidcDiscoveryError.Misconfigured);
|
||||
throw new Error(OidcError.Misconfigured);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -76,14 +77,14 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates issue `.well-known/openid-configuration`
|
||||
* Validates issuer `.well-known/openid-configuration`
|
||||
* As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
* validates that OP is compatible with Element's OIDC flow
|
||||
* @param wellKnown - json object
|
||||
@@ -93,12 +94,13 @@ const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, val
|
||||
export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => {
|
||||
if (!isRecord(wellKnown)) {
|
||||
logger.error("Issuer configuration not found or malformed");
|
||||
throw new Error(OidcDiscoveryError.OpSupport);
|
||||
throw new Error(OidcError.OpSupport);
|
||||
}
|
||||
|
||||
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"),
|
||||
@@ -114,5 +116,193 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
||||
}
|
||||
|
||||
logger.error("Issuer configuration not valid");
|
||||
throw new Error(OidcDiscoveryError.OpSupport);
|
||||
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.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
|
||||
*/
|
||||
interface JwtClaims {
|
||||
[claim: string]: unknown;
|
||||
/** The "iss" (issuer) claim identifies the principal that issued the JWT. */
|
||||
iss?: string;
|
||||
/** The "sub" (subject) claim identifies the principal that is the subject of the JWT. */
|
||||
sub?: string;
|
||||
/** The "aud" (audience) claim identifies the recipients that the JWT is intended for. */
|
||||
aud?: string | string[];
|
||||
/** The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. */
|
||||
exp?: number;
|
||||
// unused claims excluded
|
||||
}
|
||||
interface IdTokenClaims extends JwtClaims {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
const decodeIdToken = (token: string): IdTokenClaims => {
|
||||
try {
|
||||
return jwtDecode<IdTokenClaims>(token);
|
||||
} catch (error) {
|
||||
logger.error("Could not decode id_token", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate idToken
|
||||
* https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
* @param idToken - id token from token endpoint
|
||||
* @param issuer - issuer for the OP as found during discovery
|
||||
* @param clientId - this client's id as registered with the OP
|
||||
* @param nonce - nonce used in the authentication request
|
||||
* @throws when id token is invalid
|
||||
*/
|
||||
export const validateIdToken = (idToken: string | undefined, issuer: string, clientId: string, nonce: string): void => {
|
||||
try {
|
||||
if (!idToken) {
|
||||
throw new Error("No ID token");
|
||||
}
|
||||
const claims = decodeIdToken(idToken);
|
||||
|
||||
// The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim.
|
||||
if (claims.iss !== issuer) {
|
||||
throw new Error("Invalid issuer");
|
||||
}
|
||||
/**
|
||||
* The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.
|
||||
* The aud (audience) Claim MAY contain an array with more than one element.
|
||||
* The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client.
|
||||
* EW: Don't accept tokens with other untrusted audiences
|
||||
* */
|
||||
if (claims.aud !== clientId) {
|
||||
throw new Error("Invalid audience");
|
||||
}
|
||||
|
||||
/**
|
||||
* If a nonce value was sent in the Authentication Request, a nonce Claim MUST be present and its value checked
|
||||
* to verify that it is the same value as the one that was sent in the Authentication Request.
|
||||
*/
|
||||
if (claims.nonce !== nonce) {
|
||||
throw new Error("Invalid nonce");
|
||||
}
|
||||
|
||||
/**
|
||||
* The current time MUST be before the time represented by the exp Claim.
|
||||
* exp is an epoch timestamp in seconds
|
||||
* */
|
||||
if (!claims.exp || Date.now() > claims.exp * 1000) {
|
||||
throw new Error("Invalid expiry");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Invalid ID token", error);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { logger } from "../logger";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./constants";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { ICryptoCallbacks } from "../crypto";
|
||||
|
||||
/**
|
||||
* Create a new `RustCrypto` implementation
|
||||
@@ -30,12 +30,17 @@ import { ServerSideSecretStorage } from "../secret-storage";
|
||||
* @param userId - The local user's User ID.
|
||||
* @param deviceId - The local user's Device ID.
|
||||
* @param secretStorage - Interface to server-side secret storage.
|
||||
* @param cryptoCallbacks - Crypto callbacks provided by the application
|
||||
* @param storePrefix - the prefix to use on the indexeddbs created by rust-crypto.
|
||||
* If unset, a memory store will be used.
|
||||
*/
|
||||
export async function initRustCrypto(
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
secretStorage: ServerSideSecretStorage,
|
||||
cryptoCallbacks: ICryptoCallbacks,
|
||||
storePrefix: string | null,
|
||||
): Promise<RustCrypto> {
|
||||
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
@@ -48,12 +53,28 @@ export async function initRustCrypto(
|
||||
logger.info("Init OlmMachine");
|
||||
|
||||
// TODO: use the pickle key for the passphrase
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
|
||||
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(
|
||||
u,
|
||||
d,
|
||||
storePrefix ?? undefined,
|
||||
(storePrefix && "test pass") ?? undefined,
|
||||
);
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
);
|
||||
|
||||
// Tell the OlmMachine to think about its outgoing requests before we hand control back to the application.
|
||||
//
|
||||
// This is primarily a fudge to get it to correctly populate the `users_for_key_query` list, so that future
|
||||
// calls to getIdentity (etc) block until the key queries are performed.
|
||||
//
|
||||
// Note that we don't actually need to *make* any requests here; it is sufficient to tell the Rust side to think
|
||||
// about them.
|
||||
//
|
||||
// XXX: find a less hacky way to do this.
|
||||
await olmMachine.outgoingRequests();
|
||||
|
||||
logger.info("Completed rust crypto-sdk setup");
|
||||
return rustCrypto;
|
||||
}
|
||||
|
||||
+298
-20
@@ -19,7 +19,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import type { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
@@ -32,23 +32,34 @@ import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import {
|
||||
BootstrapCrossSigningOpts,
|
||||
CreateSecretStorageOpts,
|
||||
CrossSigningKey,
|
||||
CrossSigningStatus,
|
||||
CryptoCallbacks,
|
||||
DeviceVerificationStatus,
|
||||
GeneratedSecretStorageKey,
|
||||
ImportRoomKeyProgressData,
|
||||
ImportRoomKeysOpts,
|
||||
VerificationRequest,
|
||||
} from "../crypto-api";
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
|
||||
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningKey } from "../crypto/api";
|
||||
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity";
|
||||
import { secretStorageContainsCrossSigningKeys } from "./secret-storage";
|
||||
import { keyFromPassphrase } from "../crypto/key_passphrase";
|
||||
import { encodeRecoveryKey } from "../crypto/recoverykey";
|
||||
import { crypto } from "../crypto/crypto";
|
||||
import { RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification";
|
||||
import { EventType } from "../@types/event";
|
||||
import { CryptoEvent } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
*/
|
||||
export class RustCrypto implements CryptoBackend {
|
||||
export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEventMap> implements CryptoBackend {
|
||||
public globalErrorOnUnknownDevices = false;
|
||||
private _trustCrossSignedDevices = true;
|
||||
|
||||
@@ -85,7 +96,11 @@ export class RustCrypto implements CryptoBackend {
|
||||
|
||||
/** Interface to server-side secret storage */
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
|
||||
/** Crypto callbacks provided by the application */
|
||||
private readonly cryptoCallbacks: CryptoCallbacks,
|
||||
) {
|
||||
super();
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine);
|
||||
@@ -161,18 +176,6 @@ export class RustCrypto implements CryptoBackend {
|
||||
return new UserTrustLevel(false, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): undefined {
|
||||
// TODO
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cross signing information for a given user.
|
||||
*
|
||||
@@ -195,9 +198,12 @@ export class RustCrypto implements CryptoBackend {
|
||||
|
||||
public globalBlacklistUnverifiedDevices = false;
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.userHasCrossSigningKeys}.
|
||||
*/
|
||||
public async userHasCrossSigningKeys(): Promise<boolean> {
|
||||
// TODO
|
||||
return false;
|
||||
const userIdentity = await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId));
|
||||
return userIdentity !== undefined;
|
||||
}
|
||||
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
@@ -379,6 +385,103 @@ export class RustCrypto implements CryptoBackend {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#bootstrapSecretStorage}
|
||||
*/
|
||||
public async bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
setupNewSecretStorage,
|
||||
}: CreateSecretStorageOpts = {}): Promise<void> {
|
||||
// If an AES Key is already stored in the secret storage and setupNewSecretStorage is not set
|
||||
// we don't want to create a new key
|
||||
const isNewSecretStorageKeyNeeded = setupNewSecretStorage || !(await this.secretStorageHasAESKey());
|
||||
|
||||
if (isNewSecretStorageKeyNeeded) {
|
||||
if (!createSecretStorageKey) {
|
||||
throw new Error("unable to create a new secret storage key, createSecretStorageKey is not set");
|
||||
}
|
||||
|
||||
// Create a new storage key and add it to secret storage
|
||||
const recoveryKey = await createSecretStorageKey();
|
||||
await this.addSecretStorageKeyToSecretStorage(recoveryKey);
|
||||
}
|
||||
|
||||
const crossSigningStatus: RustSdkCryptoJs.CrossSigningStatus = await this.olmMachine.crossSigningStatus();
|
||||
const hasPrivateKeys =
|
||||
crossSigningStatus.hasMaster && crossSigningStatus.hasSelfSigning && crossSigningStatus.hasUserSigning;
|
||||
|
||||
// If we have cross-signing private keys cached, store them in secret
|
||||
// storage if they are not there already.
|
||||
if (
|
||||
hasPrivateKeys &&
|
||||
(isNewSecretStorageKeyNeeded || !(await secretStorageContainsCrossSigningKeys(this.secretStorage)))
|
||||
) {
|
||||
const crossSigningPrivateKeys: RustSdkCryptoJs.CrossSigningKeyExport =
|
||||
await this.olmMachine.exportCrossSigningKeys();
|
||||
|
||||
if (!crossSigningPrivateKeys.masterKey) {
|
||||
throw new Error("missing master key in cross signing private keys");
|
||||
}
|
||||
|
||||
if (!crossSigningPrivateKeys.userSigningKey) {
|
||||
throw new Error("missing user signing key in cross signing private keys");
|
||||
}
|
||||
|
||||
if (!crossSigningPrivateKeys.self_signing_key) {
|
||||
throw new Error("missing self signing key in cross signing private keys");
|
||||
}
|
||||
|
||||
await this.secretStorage.store("m.cross_signing.master", crossSigningPrivateKeys.masterKey);
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", crossSigningPrivateKeys.userSigningKey);
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", crossSigningPrivateKeys.self_signing_key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the secretStorage key to the secret storage
|
||||
* - The secret storage key must have the `keyInfo` field filled
|
||||
* - The secret storage key is set as the default key of the secret storage
|
||||
* - Call `cryptoCallbacks.cacheSecretStorageKey` when done
|
||||
*
|
||||
* @param secretStorageKey - The secret storage key to add in the secret storage.
|
||||
*/
|
||||
private async addSecretStorageKeyToSecretStorage(secretStorageKey: GeneratedSecretStorageKey): Promise<void> {
|
||||
// keyInfo is required to continue
|
||||
if (!secretStorageKey.keyInfo) {
|
||||
throw new Error("missing keyInfo field in the secret storage key");
|
||||
}
|
||||
|
||||
const secretStorageKeyObject = await this.secretStorage.addKey(
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
secretStorageKey.keyInfo,
|
||||
);
|
||||
|
||||
await this.secretStorage.setDefaultKeyId(secretStorageKeyObject.keyId);
|
||||
|
||||
this.cryptoCallbacks.cacheSecretStorageKey?.(
|
||||
secretStorageKeyObject.keyId,
|
||||
secretStorageKeyObject.keyInfo,
|
||||
secretStorageKey.privateKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a secret storage AES Key is already added in secret storage
|
||||
*
|
||||
* @returns True if an AES key is in the secret storage
|
||||
*/
|
||||
private async secretStorageHasAESKey(): Promise<boolean> {
|
||||
// See if we already have an AES secret-storage key.
|
||||
const secretStorageKeyTuple = await this.secretStorage.getKey();
|
||||
|
||||
if (!secretStorageKeyTuple) return false;
|
||||
|
||||
const [, keyInfo] = secretStorageKeyTuple;
|
||||
|
||||
// Check if the key is an AES key
|
||||
return keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningStatus}
|
||||
*/
|
||||
@@ -405,6 +508,138 @@ export class RustCrypto implements CryptoBackend {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#createRecoveryKeyFromPassphrase}
|
||||
*/
|
||||
public async createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey> {
|
||||
let key: Uint8Array;
|
||||
|
||||
const keyInfo: AddSecretStorageKeyOpts = {};
|
||||
if (password) {
|
||||
// Generate the key from the passphrase
|
||||
const derivation = await keyFromPassphrase(password);
|
||||
keyInfo.passphrase = {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: derivation.iterations,
|
||||
salt: derivation.salt,
|
||||
};
|
||||
key = derivation.key;
|
||||
} else {
|
||||
// Using the navigator crypto API to generate the private key
|
||||
key = new Uint8Array(32);
|
||||
crypto.getRandomValues(key);
|
||||
}
|
||||
|
||||
const encodedPrivateKey = encodeRecoveryKey(key);
|
||||
return {
|
||||
keyInfo,
|
||||
encodedPrivateKey,
|
||||
privateKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns to-device verification requests that are already in progress for the given user id.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#getVerificationRequestsToDeviceInProgress}
|
||||
*
|
||||
* @param userId - the ID of the user to query
|
||||
*
|
||||
* @returns the VerificationRequests that are in progress
|
||||
*/
|
||||
public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
|
||||
const requests: RustSdkCryptoJs.VerificationRequest[] = this.olmMachine.getVerificationRequests(
|
||||
new RustSdkCryptoJs.UserId(this.userId),
|
||||
);
|
||||
return requests
|
||||
.filter((request) => request.roomId === undefined)
|
||||
.map(
|
||||
(request) =>
|
||||
new RustVerificationRequest(
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this.supportedVerificationMethods,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* Implementation of {@link CryptoApi#findVerificationRequestDMInProgress}
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
*
|
||||
*/
|
||||
public findVerificationRequestDMInProgress(roomId: string): undefined {
|
||||
// TODO
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification methods we offer to the other side during an interactive verification.
|
||||
*
|
||||
* If `undefined`, we will offer all the methods supported by the Rust SDK.
|
||||
*/
|
||||
public supportedVerificationMethods: string[] | undefined;
|
||||
|
||||
/**
|
||||
* Send a verification request to our other devices.
|
||||
*
|
||||
* If a verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#requestOwnUserVerification}.
|
||||
*
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
public async requestOwnUserVerification(): Promise<VerificationRequest> {
|
||||
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity(
|
||||
new RustSdkCryptoJs.UserId(this.userId),
|
||||
);
|
||||
if (userIdentity === undefined) {
|
||||
throw new Error("cannot request verification for this device when there is no existing cross-signing key");
|
||||
}
|
||||
|
||||
const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] =
|
||||
await userIdentity.requestVerification(
|
||||
this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
|
||||
return new RustVerificationRequest(request, this.outgoingRequestProcessor, this.supportedVerificationMethods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an interactive verification with the given device.
|
||||
*
|
||||
* If a verification is already in flight, returns it. Otherwise, initiates a new one.
|
||||
*
|
||||
* Implementation of {@link CryptoApi#requestDeviceVerification}.
|
||||
*
|
||||
* @param userId - ID of the owner of the device to verify
|
||||
* @param deviceId - ID of the device to verify
|
||||
*
|
||||
* @returns a VerificationRequest when the request has been sent to the other party.
|
||||
*/
|
||||
public async requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest> {
|
||||
const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.DeviceId(deviceId),
|
||||
);
|
||||
|
||||
if (!device) {
|
||||
throw new Error("Not a known device");
|
||||
}
|
||||
|
||||
const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] =
|
||||
await device.requestVerification(
|
||||
this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
|
||||
return new RustVerificationRequest(request, this.outgoingRequestProcessor, this.supportedVerificationMethods);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -446,10 +681,18 @@ export class RustCrypto implements CryptoBackend {
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
*/
|
||||
public preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
|
||||
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
|
||||
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
|
||||
// one-time-keys, or fallback keys, so just pass empty data.
|
||||
return this.receiveSyncChanges({ events });
|
||||
const processed = await this.receiveSyncChanges({ events });
|
||||
|
||||
// look for interesting to-device messages
|
||||
for (const message of processed) {
|
||||
if (message.type === EventType.KeyVerificationRequest) {
|
||||
this.onIncomingKeyVerificationRequest(message.sender, message.content);
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
/** called by the sync loop to process one time key counts and unused fallback keys
|
||||
@@ -527,6 +770,32 @@ export class RustCrypto implements CryptoBackend {
|
||||
this.outgoingRequestLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming m.key.verification request event
|
||||
*
|
||||
* @param sender - the sender of the event
|
||||
* @param content - the content of the event
|
||||
*/
|
||||
private onIncomingKeyVerificationRequest(sender: string, content: IContent): void {
|
||||
const transactionId = content.transaction_id;
|
||||
if (!transactionId || !sender) {
|
||||
// not a valid request: ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RustSdkCryptoJs.VerificationRequest | undefined = this.olmMachine.getVerificationRequest(
|
||||
new RustSdkCryptoJs.UserId(sender),
|
||||
transactionId,
|
||||
);
|
||||
|
||||
if (request) {
|
||||
this.emit(
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
new RustVerificationRequest(request, this.outgoingRequestProcessor, this.supportedVerificationMethods),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Other public functions
|
||||
@@ -715,3 +984,12 @@ class EventDecryptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RustCryptoEvents = CryptoEvent.VerificationRequestReceived;
|
||||
|
||||
type RustCryptoEventMap = {
|
||||
/**
|
||||
* Fires when a key verification request is received.
|
||||
*/
|
||||
[CryptoEvent.VerificationRequestReceived]: (request: VerificationRequest) => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
import { Emoji } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import {
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
VerificationPhase,
|
||||
VerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
VerificationRequestEventHandlerMap,
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
VerifierEventHandlerMap,
|
||||
} from "../crypto-api/verification";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
|
||||
/**
|
||||
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
|
||||
*/
|
||||
export class RustVerificationRequest
|
||||
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap>
|
||||
implements VerificationRequest
|
||||
{
|
||||
/** Are we in the process of sending an `m.key.verification.ready` event? */
|
||||
private _accepting = false;
|
||||
|
||||
/** Are we in the process of sending an `m.key.verification.cancellation` event? */
|
||||
private _cancelling = false;
|
||||
|
||||
private _verifier: Verifier | undefined;
|
||||
|
||||
/**
|
||||
* Construct a new RustVerificationRequest to wrap the rust-level `VerificationRequest`.
|
||||
*
|
||||
* @param inner - VerificationRequest from the Rust SDK
|
||||
* @param outgoingRequestProcessor - `OutgoingRequestProcessor` to use for making outgoing HTTP requests
|
||||
* @param supportedVerificationMethods - Verification methods to use when `accept()` is called
|
||||
*/
|
||||
public constructor(
|
||||
private readonly inner: RustSdkCryptoJs.VerificationRequest,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly supportedVerificationMethods: string[] | undefined,
|
||||
) {
|
||||
super();
|
||||
|
||||
const onChange = async (): Promise<void> => {
|
||||
// if we now have a `Verification` where we lacked one before, wrap it.
|
||||
// TODO: QR support
|
||||
if (this._verifier === undefined) {
|
||||
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
|
||||
if (verification instanceof RustSdkCryptoJs.Sas) {
|
||||
this._verifier = new RustSASVerifier(verification, this, outgoingRequestProcessor);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(VerificationRequestEvent.Change);
|
||||
};
|
||||
inner.registerChangesCallback(onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique ID for this verification request.
|
||||
*
|
||||
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
|
||||
*/
|
||||
public get transactionId(): string | undefined {
|
||||
return this.inner.flowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an in-room verification, the ID of the room.
|
||||
*
|
||||
* For to-device verifications, `undefined`.
|
||||
*/
|
||||
public get roomId(): string | undefined {
|
||||
return this.inner.roomId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this request was initiated by the local client.
|
||||
*
|
||||
* For in-room verifications, the initiator is who sent the `m.key.verification.request` event.
|
||||
* For to-device verifications, the initiator is who sent the `m.key.verification.start` event.
|
||||
*/
|
||||
public get initiatedByMe(): boolean {
|
||||
return this.inner.weStarted();
|
||||
}
|
||||
|
||||
/** The user id of the other party in this request */
|
||||
public get otherUserId(): string {
|
||||
return this.inner.otherUserId.toString();
|
||||
}
|
||||
|
||||
/** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */
|
||||
public get otherDeviceId(): string | undefined {
|
||||
return this.inner.otherDeviceId?.toString();
|
||||
}
|
||||
|
||||
/** True if the other party in this request is one of this user's own devices. */
|
||||
public get isSelfVerification(): boolean {
|
||||
return this.inner.isSelfVerification();
|
||||
}
|
||||
|
||||
/** current phase of the request. */
|
||||
public get phase(): VerificationPhase {
|
||||
const phase = this.inner.phase();
|
||||
|
||||
switch (phase) {
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Created:
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Requested:
|
||||
return VerificationPhase.Requested;
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Ready:
|
||||
// if we're still sending the `m.key.verification.ready`, that counts as "Requested" in the js-sdk's
|
||||
// parlance.
|
||||
return this._accepting ? VerificationPhase.Requested : VerificationPhase.Ready;
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Transitioned:
|
||||
return VerificationPhase.Started;
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Done:
|
||||
return VerificationPhase.Done;
|
||||
case RustSdkCryptoJs.VerificationRequestPhase.Cancelled:
|
||||
return VerificationPhase.Cancelled;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown verification phase ${phase}`);
|
||||
}
|
||||
|
||||
/** True if the request has sent its initial event and needs more events to complete
|
||||
* (ie it is in phase `Requested`, `Ready` or `Started`).
|
||||
*/
|
||||
public get pending(): boolean {
|
||||
if (this.inner.isPassive()) return false;
|
||||
const phase = this.phase;
|
||||
return phase !== VerificationPhase.Done && phase !== VerificationPhase.Cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received
|
||||
* the remote echo which causes a transition to {@link VerificationPhase.Ready}.
|
||||
*/
|
||||
public get accepting(): boolean {
|
||||
return this._accepting;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received
|
||||
* the remote echo which causes a transition to {@link VerificationPhase.Cancelled}).
|
||||
*/
|
||||
public get declining(): boolean {
|
||||
return this._cancelling;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remaining number of ms before the request will be automatically cancelled.
|
||||
*
|
||||
* `null` indicates that there is no timeout
|
||||
*/
|
||||
public get timeout(): number | null {
|
||||
return this.inner.timeRemainingMillis();
|
||||
}
|
||||
|
||||
/** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */
|
||||
public get methods(): string[] {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
/** the method picked in the .start event */
|
||||
public get chosenMethod(): string | null {
|
||||
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
|
||||
// TODO: this isn't quite right. The existence of a Verification doesn't prove that we have .started.
|
||||
if (verification instanceof RustSdkCryptoJs.Sas) {
|
||||
return "m.sas.v1";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* For methods that need to be supported by both ends, use the `methods` property.
|
||||
*
|
||||
* @param method - the method to check
|
||||
* @returns true if the other party said they supported the method
|
||||
*/
|
||||
public otherPartySupportsMethod(method: string): boolean {
|
||||
const theirMethods: RustSdkCryptoJs.VerificationMethod[] | undefined = this.inner.theirSupportedMethods;
|
||||
if (theirMethods === undefined) {
|
||||
// no message from the other side yet
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredMethod = verificationMethodsByIdentifier[method];
|
||||
return theirMethods.some((m) => m === requiredMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the request, sending a .ready event to the other party
|
||||
*
|
||||
* @returns Promise which resolves when the event has been sent.
|
||||
*/
|
||||
public async accept(): Promise<void> {
|
||||
if (this.inner.phase() !== RustSdkCryptoJs.VerificationRequestPhase.Requested || this._accepting) {
|
||||
throw new Error(`Cannot accept a verification request in phase ${this.phase}`);
|
||||
}
|
||||
|
||||
this._accepting = true;
|
||||
try {
|
||||
const req: undefined | OutgoingRequest =
|
||||
this.supportedVerificationMethods === undefined
|
||||
? this.inner.accept()
|
||||
: this.inner.acceptWithMethods(
|
||||
this.supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
if (req) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
} finally {
|
||||
this._accepting = false;
|
||||
}
|
||||
|
||||
// phase may have changed, so emit a 'change' event
|
||||
this.emit(VerificationRequestEvent.Change);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the request, sending a cancellation to the other party
|
||||
*
|
||||
* @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code`
|
||||
* (defaults to `m.user`).
|
||||
*
|
||||
* @returns Promise which resolves when the event has been sent.
|
||||
*/
|
||||
public async cancel(params?: { reason?: string; code?: string }): Promise<void> {
|
||||
if (this._cancelling) {
|
||||
// already cancelling; do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
this._cancelling = true;
|
||||
try {
|
||||
const req: undefined | OutgoingRequest = this.inner.cancel();
|
||||
if (req) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
} finally {
|
||||
this._cancelling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link Verifier} to do this verification via a particular method.
|
||||
*
|
||||
* If a verifier has already been created for this request, returns that verifier.
|
||||
*
|
||||
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier#verifier} on the
|
||||
* returned verifier.
|
||||
*
|
||||
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
* @param targetDevice - details of where to send the request to.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*/
|
||||
public beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an `m.key.verification.start` event to start verification via a particular method.
|
||||
*
|
||||
* Implementation of {@link Crypto.VerificationRequest#startVerification}.
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
*/
|
||||
public async startVerification(method: string): Promise<Verifier> {
|
||||
if (method !== "m.sas.v1") {
|
||||
throw new Error(`Unsupported verification method ${method}`);
|
||||
}
|
||||
|
||||
const res:
|
||||
| [RustSdkCryptoJs.Sas, RustSdkCryptoJs.RoomMessageRequest | RustSdkCryptoJs.ToDeviceRequest]
|
||||
| undefined = await this.inner.startSas();
|
||||
|
||||
if (res) {
|
||||
const [, req] = res;
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
|
||||
// this should have triggered the onChange callback, and we should now have a verifier
|
||||
if (!this._verifier) {
|
||||
throw new Error("Still no verifier after startSas() call");
|
||||
}
|
||||
|
||||
return this._verifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verifier which is doing the actual verification, once the method has been established.
|
||||
* Only defined when the `phase` is Started.
|
||||
*/
|
||||
public get verifier(): Verifier | undefined {
|
||||
return this._verifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub implementation of {@link Crypto.VerificationRequest#getQRCodeBytes}.
|
||||
*/
|
||||
public getQRCodeBytes(): Buffer | undefined {
|
||||
// TODO
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Implementation of {@link Crypto.VerificationRequest#generateQRCode}.
|
||||
*/
|
||||
public async generateQRCode(): Promise<Buffer | undefined> {
|
||||
// TODO
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling
|
||||
* this verification.
|
||||
*/
|
||||
public get cancellationCode(): string | null {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the user that cancelled the request.
|
||||
*
|
||||
* Only defined when phase is Cancelled
|
||||
*/
|
||||
public get cancellingUserId(): string | undefined {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
export class RustSASVerifier extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap> implements Verifier {
|
||||
/** A promise which completes when the verification completes (or rejects when it is cancelled/fails) */
|
||||
private readonly completionPromise: Promise<void>;
|
||||
|
||||
private callbacks: ShowSasCallbacks | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly inner: RustSdkCryptoJs.Sas,
|
||||
_verificationRequest: RustVerificationRequest,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.completionPromise = new Promise<void>((resolve, reject) => {
|
||||
const onChange = async (): Promise<void> => {
|
||||
this.updateCallbacks();
|
||||
|
||||
if (this.inner.isDone()) {
|
||||
resolve(undefined);
|
||||
} else if (this.inner.isCancelled()) {
|
||||
const cancelInfo = this.inner.cancelInfo()!;
|
||||
reject(
|
||||
new Error(
|
||||
`Verification cancelled by ${
|
||||
cancelInfo.cancelledbyUs() ? "us" : "them"
|
||||
} with code ${cancelInfo.cancelCode()}: ${cancelInfo.reason()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
inner.registerChangesCallback(onChange);
|
||||
});
|
||||
// stop the runtime complaining if nobody catches a failure
|
||||
this.completionPromise.catch(() => null);
|
||||
}
|
||||
|
||||
/** if we can now show the callbacks, do so */
|
||||
private updateCallbacks(): void {
|
||||
if (this.callbacks === null) {
|
||||
const emoji: Array<Emoji> | undefined = this.inner.emoji();
|
||||
const decimal = this.inner.decimals() as [number, number, number] | undefined;
|
||||
|
||||
if (emoji === undefined && decimal === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.callbacks = {
|
||||
sas: {
|
||||
decimal: decimal,
|
||||
emoji: emoji?.map((e) => [e.symbol, e.description]),
|
||||
},
|
||||
confirm: async (): Promise<void> => {
|
||||
const requests: Array<OutgoingRequest> = await this.inner.confirm();
|
||||
for (const m of requests) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(m);
|
||||
}
|
||||
},
|
||||
mismatch: (): void => {
|
||||
throw new Error("impl");
|
||||
},
|
||||
cancel: (): void => {
|
||||
throw new Error("impl");
|
||||
},
|
||||
};
|
||||
this.emit(VerifierEvent.ShowSas, this.callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the verification has been cancelled, either by us or the other side.
|
||||
*/
|
||||
public get hasBeenCancelled(): boolean {
|
||||
return this.inner.isCancelled();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the other user in the verification process.
|
||||
*/
|
||||
public get userId(): string {
|
||||
return this.inner.otherUserId.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the key verification, if it has not already been started.
|
||||
*
|
||||
* This means sending a `m.key.verification.start` if we are the first responder, or a `m.key.verification.accept`
|
||||
* if the other side has already sent a start event.
|
||||
*
|
||||
* @returns Promise which resolves when the verification has completed, or rejects if the verification is cancelled
|
||||
* or times out.
|
||||
*/
|
||||
public async verify(): Promise<void> {
|
||||
const req: undefined | OutgoingRequest = this.inner.accept();
|
||||
if (req) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
await this.completionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a verification.
|
||||
*
|
||||
* We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise
|
||||
* will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted.
|
||||
*
|
||||
* @param e - the reason for the cancellation.
|
||||
*/
|
||||
public cancel(e: Error): void {
|
||||
// TODO: something with `e`
|
||||
const req: undefined | OutgoingRequest = this.inner.cancel();
|
||||
if (req) {
|
||||
this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details for an SAS verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
|
||||
* the SAS matches.
|
||||
*/
|
||||
public getShowSasCallbacks(): ShowSasCallbacks | null {
|
||||
return this.callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details for reciprocating QR code verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
|
||||
* already scanned our QR code), and we are waiting for the user to confirm.
|
||||
*/
|
||||
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */
|
||||
const verificationMethodsByIdentifier: Record<string, RustSdkCryptoJs.VerificationMethod> = {
|
||||
"m.sas.v1": RustSdkCryptoJs.VerificationMethod.SasV1,
|
||||
"m.qr_code.scan.v1": RustSdkCryptoJs.VerificationMethod.QrCodeScanV1,
|
||||
"m.qr_code.show.v1": RustSdkCryptoJs.VerificationMethod.QrCodeShowV1,
|
||||
"m.reciprocate.v1": RustSdkCryptoJs.VerificationMethod.ReciprocateV1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a specced verification method identifier into a rust-side `VerificationMethod`.
|
||||
*
|
||||
* @param method - specced method identifier, for example `m.sas.v1`.
|
||||
* @returns Rust-side `VerificationMethod` corresponding to `method`.
|
||||
* @throws An error if the method is unknown.
|
||||
*/
|
||||
export function verificationMethodIdentifierToMethod(method: string): RustSdkCryptoJs.VerificationMethod {
|
||||
const meth = verificationMethodsByIdentifier[method];
|
||||
if (meth === undefined) {
|
||||
throw new Error(`Unknown verification method ${method}`);
|
||||
}
|
||||
return meth;
|
||||
}
|
||||
@@ -338,12 +338,16 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
|
||||
/**
|
||||
* Clear the entire database. This should be used when logging out of a client
|
||||
* to prevent mixing data between accounts.
|
||||
* to prevent mixing data between accounts. Closes the database.
|
||||
* @returns Resolved when the database is cleared.
|
||||
*/
|
||||
public clearDatabase(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
logger.log(`Removing indexeddb instance: ${this.dbName}`);
|
||||
|
||||
// Close the database first to avoid firing unexpected close events
|
||||
this.db?.close();
|
||||
|
||||
const req = this.indexedDB.deleteDatabase(this.dbName);
|
||||
|
||||
req.onblocked = (): void => {
|
||||
|
||||
+15
-11
@@ -407,6 +407,10 @@ export class SyncApi {
|
||||
const client = this.client;
|
||||
this._peekRoom = this.createRoom(roomId);
|
||||
return this.client.roomInitialSync(roomId, 20).then((response) => {
|
||||
if (this._peekRoom?.roomId !== roomId) {
|
||||
throw new Error("Peeking aborted");
|
||||
}
|
||||
|
||||
// make sure things are init'd
|
||||
response.messages = response.messages || { chunk: [] };
|
||||
response.messages.chunk = response.messages.chunk || [];
|
||||
@@ -438,31 +442,31 @@ export class SyncApi {
|
||||
// fire off pagination requests in response to the Room.timeline
|
||||
// events.
|
||||
if (response.messages.start) {
|
||||
this._peekRoom!.oldState.paginationToken = response.messages.start;
|
||||
this._peekRoom.oldState.paginationToken = response.messages.start;
|
||||
}
|
||||
|
||||
// set the state of the room to as it was after the timeline executes
|
||||
this._peekRoom!.oldState.setStateEvents(oldStateEvents);
|
||||
this._peekRoom!.currentState.setStateEvents(stateEvents);
|
||||
this._peekRoom.oldState.setStateEvents(oldStateEvents);
|
||||
this._peekRoom.currentState.setStateEvents(stateEvents);
|
||||
|
||||
this.resolveInvites(this._peekRoom!);
|
||||
this._peekRoom!.recalculate();
|
||||
this.resolveInvites(this._peekRoom);
|
||||
this._peekRoom.recalculate();
|
||||
|
||||
// roll backwards to diverge old state. addEventsToTimeline
|
||||
// will overwrite the pagination token, so make sure it overwrites
|
||||
// it with the right thing.
|
||||
this._peekRoom!.addEventsToTimeline(
|
||||
this._peekRoom.addEventsToTimeline(
|
||||
messages.reverse(),
|
||||
true,
|
||||
this._peekRoom!.getLiveTimeline(),
|
||||
this._peekRoom.getLiveTimeline(),
|
||||
response.messages.start,
|
||||
);
|
||||
|
||||
client.store.storeRoom(this._peekRoom!);
|
||||
client.emit(ClientEvent.Room, this._peekRoom!);
|
||||
client.store.storeRoom(this._peekRoom);
|
||||
client.emit(ClientEvent.Room, this._peekRoom);
|
||||
|
||||
this.peekPoll(this._peekRoom!);
|
||||
return this._peekRoom!;
|
||||
this.peekPoll(this._peekRoom);
|
||||
return this._peekRoom;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,19 @@ export class MediaHandler extends TypedEventEmitter<
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
|
||||
if (this.localUserMediaStream === mediaStream) {
|
||||
// if we have this stream cahced, remove it, because we've stopped it
|
||||
this.localUserMediaStream = undefined;
|
||||
} else {
|
||||
// If it's not the same stream. remove any tracks from the cached stream that
|
||||
// we have just stopped, and if we do stop any, call the same method on the
|
||||
// cached stream too in order to stop all its tracks (in case they are different)
|
||||
// and un-cache it.
|
||||
for (const track of mediaStream.getTracks()) {
|
||||
if (this.localUserMediaStream?.getTrackById(track.id)) {
|
||||
this.stopUserMediaStream(this.localUserMediaStream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user