Compare commits
53 Commits
v33.0.0
...
v34.0.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c589923b | |||
| 1586de44bd | |||
| 04ea2a4e5d | |||
| b71099d0f8 | |||
| ab6260074d | |||
| 25a7c9e140 | |||
| 78b6b878bd | |||
| 4ccb72c0f2 | |||
| 9f1aebbdcb | |||
| 6a15e8f1a0 | |||
| 238eea0ef5 | |||
| ab6f86536f | |||
| 819fc75202 | |||
| c70aa33367 | |||
| 240b43b652 | |||
| 697d5d31d1 | |||
| b1701ff571 | |||
| c55289ec65 | |||
| 987ec1e62f | |||
| 76b240cf57 | |||
| a4c4e7e275 | |||
| 3f5a994a24 | |||
| d754392410 | |||
| 7ecaa53e34 | |||
| 222e95d33f | |||
| 2ee43cade7 | |||
| 9218f6380c | |||
| 661ba76763 | |||
| 4cb851c51a | |||
| 5a3d24abc2 | |||
| 3eed74f1a6 | |||
| 10e7a2d997 | |||
| 969ecdb6fb | |||
| e8b91f2729 | |||
| f80366ff30 | |||
| 395c3cfcd6 | |||
| f95954c233 | |||
| fa5f2d389a | |||
| 9fc557fc6b | |||
| 6436fbb99f | |||
| 87c2ac3ffa | |||
| 43022d5b2f | |||
| a0fadeb4ec | |||
| a3cea8ce7d | |||
| c88487da07 | |||
| 89875b8e31 | |||
| 9c94393d76 | |||
| 131e81401a | |||
| 5c27e30302 | |||
| 8dfb6de3cc | |||
| 042610310f | |||
| 8535604200 | |||
| 3ee64722c5 |
@@ -49,6 +49,22 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
|
||||
"no-restricted-properties": [
|
||||
"error",
|
||||
{
|
||||
object: "window",
|
||||
property: "setImmediate",
|
||||
message: "Use setTimeout instead.",
|
||||
},
|
||||
],
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
name: "setImmediate",
|
||||
message: "Use setTimeout instead.",
|
||||
},
|
||||
],
|
||||
|
||||
"import/no-restricted-paths": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
Changes in [33.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.1.0) (2024-06-18)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MSC4108 support OIDC QR code login ([#4134](https://github.com/matrix-org/matrix-js-sdk/pull/4134)). Contributed by @t3chguy.
|
||||
* Add crypto methods for export and import of secrets bundle ([#4227](https://github.com/matrix-org/matrix-js-sdk/pull/4227)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix screen sharing in recent Chrome ([#4243](https://github.com/matrix-org/matrix-js-sdk/pull/4243)). Contributed by @RiotRobot.
|
||||
* Fix incorrect assumptions about required fields in /search response ([#4228](https://github.com/matrix-org/matrix-js-sdk/pull/4228)). Contributed by @t3chguy.
|
||||
* Fix the queueToDevice tests for the new fakeindexeddb ([#4225](https://github.com/matrix-org/matrix-js-sdk/pull/4225)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [33.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.0.0) (2024-06-04)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
@@ -95,7 +95,7 @@ Object.keys(client.store.rooms).forEach((roomId) => {
|
||||
|
||||
## Authenticated media
|
||||
|
||||
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) will require clients, like
|
||||
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) (Matrix 1.11) will require clients, like
|
||||
yours, to include an `Authorization` header when `/download`ing or `/thumbnail`ing media. For NodeJS environments this
|
||||
may be as easy as the following code snippet, though web browsers may need to use [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
||||
to append the header when using the endpoints in `<img />` elements and similar.
|
||||
|
||||
+3
-3
@@ -17,9 +17,9 @@ module.exports = {
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-numeric-separator",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-proposal-object-rest-spread",
|
||||
"@babel/plugin-transform-numeric-separator",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-object-rest-spread",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-runtime",
|
||||
],
|
||||
|
||||
+8
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "33.0.0",
|
||||
"version": "34.0.0-rc.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -53,7 +53,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^5.0.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^6.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -65,7 +65,7 @@
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "9"
|
||||
"uuid": "10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@action-validator/cli": "^0.6.0",
|
||||
@@ -74,10 +74,10 @@
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/eslint-parser": "^7.12.10",
|
||||
"@babel/eslint-plugin": "^7.12.10",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.1",
|
||||
"@babel/plugin-proposal-numeric-separator": "^7.12.7",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-class-properties": "^7.12.1",
|
||||
"@babel/plugin-transform-numeric-separator": "^7.12.7",
|
||||
"@babel/plugin-transform-object-rest-spread": "^7.12.1",
|
||||
"@babel/plugin-transform-runtime": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
@@ -87,7 +87,6 @@
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/domexception": "^4.0.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
@@ -96,7 +95,6 @@
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
@@ -105,7 +103,7 @@
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^48.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-tsdoc": "^0.3.0",
|
||||
"eslint-plugin-unicorn": "^53.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
@@ -119,7 +117,7 @@
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"prettier": "3.3.2",
|
||||
"rimraf": "^5.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.25.10",
|
||||
|
||||
@@ -630,6 +630,27 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
@@ -654,6 +675,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports that the user
|
||||
// was invited at the time the event was sent, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
|
||||
// and instead get some other error, since the user should
|
||||
// have gotten the key for the event.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
@@ -676,6 +721,29 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports the user's
|
||||
// membership, and reports that the user was joined, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
|
||||
// instead get some other error.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("Decryption fails with Unable to decrypt for other errors", async () => {
|
||||
|
||||
@@ -796,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
|
||||
const result = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
expect(result).toBeTruthy();
|
||||
jest.runAllTimers();
|
||||
jest.advanceTimersByTime(10 * 60 * 1000);
|
||||
await failurePromise;
|
||||
|
||||
// Fix the endpoint to do successful uploads
|
||||
@@ -829,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
|
||||
// run the timers, which will make the backup loop redo the request
|
||||
await jest.runAllTimersAsync();
|
||||
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
|
||||
await successPromise;
|
||||
await allKeysUploadedPromise;
|
||||
});
|
||||
|
||||
@@ -257,7 +257,7 @@ describe("MatrixClient", function () {
|
||||
.when("POST", "/knock/" + encodeURIComponent(roomId))
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ reason: opts.reason });
|
||||
expect(request.queryParams).toEqual({ server_name: opts.viaServers });
|
||||
expect(request.queryParams).toEqual({ server_name: opts.viaServers, via: opts.viaServers });
|
||||
})
|
||||
.respond(200, { room_id: roomId });
|
||||
|
||||
@@ -1293,18 +1293,109 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
describe("getCapabilities", () => {
|
||||
it("should cache by default", async () => {
|
||||
it("should return cached capabilities if present", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: {
|
||||
"m.change_password": false,
|
||||
},
|
||||
capabilities: capsObject,
|
||||
});
|
||||
const prom = httpBackend.flushAllExpected();
|
||||
const capabilities1 = await client.getCapabilities();
|
||||
const capabilities2 = await client.getCapabilities();
|
||||
|
||||
client.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(await client.getCapabilities()).toEqual(capsObject);
|
||||
});
|
||||
|
||||
it("should fetch capabilities if cache not present", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
|
||||
const capsPromise = client.getCapabilities();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(await capsPromise).toEqual(capsObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedCapabilities", () => {
|
||||
it("should return cached capabilities or undefined", async () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
|
||||
expect(client.getCachedCapabilities()).toBeUndefined();
|
||||
|
||||
client.startClient();
|
||||
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(client.getCachedCapabilities()).toEqual(capsObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCapabilities", () => {
|
||||
const capsObject = {
|
||||
"m.change_password": false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: capsObject,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should always fetch capabilities and then cache", async () => {
|
||||
const prom = client.fetchCapabilities();
|
||||
await httpBackend.flushAllExpected();
|
||||
const caps = await prom;
|
||||
|
||||
expect(caps).toEqual(capsObject);
|
||||
});
|
||||
|
||||
it("should write-through the cache", async () => {
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
|
||||
client.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(client.getCachedCapabilities()).toEqual(capsObject);
|
||||
|
||||
const newCapsObject = {
|
||||
"m.change_password": true,
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {
|
||||
capabilities: newCapsObject,
|
||||
});
|
||||
|
||||
const prom = client.fetchCapabilities();
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
|
||||
expect(capabilities1).toStrictEqual(capabilities2);
|
||||
expect(client.getCachedCapabilities()).toEqual(newCapsObject);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -105,13 +105,13 @@ describe("MatrixClient syncing errors", () => {
|
||||
|
||||
await client!.startClient();
|
||||
expect(await syncEvents[0].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[1].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("MatrixClient syncing errors", () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.get("end:capabilities", {})
|
||||
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
|
||||
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
|
||||
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
Copyright 2024 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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import {
|
||||
MSC4108FailureReason,
|
||||
MSC4108RendezvousSession,
|
||||
MSC4108SecureChannel,
|
||||
MSC4108SignInWithQR,
|
||||
PayloadType,
|
||||
RendezvousError,
|
||||
} from "../../../src/rendezvous";
|
||||
import { defer } from "../../../src/utils";
|
||||
import {
|
||||
ClientPrefix,
|
||||
DEVICE_CODE_SCOPE,
|
||||
IHttpOpts,
|
||||
IMyDevice,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
MatrixHttpApi,
|
||||
} from "../../../src";
|
||||
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
|
||||
const baseUrl = "https://example.com";
|
||||
const crypto = {
|
||||
exportSecretsForQrLogin: jest.fn(),
|
||||
};
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature(feature: string) {
|
||||
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
baseUrl,
|
||||
getDomain: () => "example.com",
|
||||
getDevice: jest.fn(),
|
||||
getCrypto: jest.fn(() => crypto),
|
||||
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
|
||||
} as unknown as MatrixClient;
|
||||
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
|
||||
baseUrl: client.baseUrl,
|
||||
prefix: ClientPrefix.Unstable,
|
||||
onlyData: true,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("MSC4108SignInWithQR", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.get(
|
||||
"https://issuer/.well-known/openid-configuration",
|
||||
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
|
||||
);
|
||||
fetchMock.get("https://issuer/jwks", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
const url = "https://fallbackserver/rz/123";
|
||||
const deviceId = "DEADB33F";
|
||||
const verificationUri = "https://example.com/verify";
|
||||
const verificationUriComplete = "https://example.com/verify/complete";
|
||||
|
||||
it("should generate qr code data as expected", async () => {
|
||||
const session = new MSC4108RendezvousSession({
|
||||
url,
|
||||
});
|
||||
const channel = new MSC4108SecureChannel(session);
|
||||
const login = new MSC4108SignInWithQR(channel, false);
|
||||
|
||||
await login.generateCode();
|
||||
const code = login.code;
|
||||
expect(code).toHaveLength(71);
|
||||
const text = new TextDecoder().decode(code);
|
||||
expect(text.startsWith("MATRIX")).toBeTruthy();
|
||||
expect(text.endsWith(url)).toBeTruthy();
|
||||
|
||||
// Assert that the code is stable
|
||||
await login.generateCode();
|
||||
expect(login.code).toEqual(code);
|
||||
});
|
||||
|
||||
describe("should be able to connect as a reciprocating device", () => {
|
||||
let client: MatrixClient;
|
||||
let ourLogin: MSC4108SignInWithQR;
|
||||
let opponentLogin: MSC4108SignInWithQR;
|
||||
|
||||
beforeEach(async () => {
|
||||
let ourData = defer<string>();
|
||||
let opponentData = defer<string>();
|
||||
|
||||
const ourMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
ourData.resolve(newData);
|
||||
}),
|
||||
receive: jest.fn(() => {
|
||||
const prom = opponentData.promise;
|
||||
prom.then(() => {
|
||||
opponentData = defer();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
url,
|
||||
cancelled: false,
|
||||
cancel: () => {
|
||||
// @ts-ignore
|
||||
ourMockSession.cancelled = true;
|
||||
ourData.resolve("");
|
||||
},
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const opponentMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
opponentData.resolve(newData);
|
||||
}),
|
||||
receive: jest.fn(() => {
|
||||
const prom = ourData.promise;
|
||||
prom.then(() => {
|
||||
ourData = defer();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
|
||||
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
|
||||
|
||||
const ourChannel = new MSC4108SecureChannel(ourMockSession);
|
||||
const qrCodeData = QrCodeData.fromBytes(
|
||||
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
|
||||
);
|
||||
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
|
||||
|
||||
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
|
||||
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share server name & check code", async () => {
|
||||
await Promise.all([
|
||||
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
|
||||
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ serverName: client.getDomain() }),
|
||||
]);
|
||||
|
||||
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share verificationUri", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
|
||||
verificationUri: verificationUriComplete,
|
||||
}),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
verification_uri_complete: verificationUriComplete,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort if device already exists", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort on unsupported protocol", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
|
||||
"Received a request for an unsupported protocol",
|
||||
),
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
opponentLogin.send({
|
||||
type: PayloadType.Protocol,
|
||||
protocol: "device_authorization_grant_v2",
|
||||
device_authorization_grant: {
|
||||
verification_uri: verificationUri,
|
||||
},
|
||||
device_id: deviceId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should be able to connect with opponent and share secrets", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
|
||||
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
};
|
||||
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
|
||||
|
||||
const payload = {
|
||||
secrets: expect.objectContaining(secrets),
|
||||
};
|
||||
await Promise.all([
|
||||
expect(ourProm).resolves.toEqual(payload),
|
||||
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should abort if device doesn't come up by timeout", async () => {
|
||||
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
|
||||
(<Function>fn)();
|
||||
return -1;
|
||||
});
|
||||
jest.spyOn(Date, "now").mockImplementation(() => {
|
||||
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
|
||||
});
|
||||
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
// @ts-ignore
|
||||
await opponentLogin.send({
|
||||
type: PayloadType.Success,
|
||||
});
|
||||
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
await expect(ourProm).rejects.toThrow("New device not found");
|
||||
});
|
||||
|
||||
it("should abort on unexpected errors", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
// @ts-ignore
|
||||
await opponentLogin.send({
|
||||
type: PayloadType.Success,
|
||||
});
|
||||
mocked(client.getDevice).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
|
||||
);
|
||||
|
||||
await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
|
||||
});
|
||||
|
||||
it("should abort on declined login", async () => {
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
await ourLogin.declineLoginOnExistingDevice();
|
||||
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
|
||||
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not send secrets if user cancels", async () => {
|
||||
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
|
||||
(<Function>fn)();
|
||||
return -1;
|
||||
});
|
||||
|
||||
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
|
||||
|
||||
// We don't have the new device side of this flow implemented at this time so mock it
|
||||
// @ts-ignore
|
||||
ourLogin.expectingNewDeviceId = "DEADB33F";
|
||||
|
||||
const ourProm = ourLogin.shareSecrets();
|
||||
const opponentProm = opponentLogin.shareSecrets();
|
||||
|
||||
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
const deferred = defer<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deferred.promise);
|
||||
|
||||
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
|
||||
deferred.resolve({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
};
|
||||
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
|
||||
|
||||
await Promise.all([
|
||||
expect(ourProm).rejects.toThrow("User cancelled"),
|
||||
expect(opponentProm).rejects.toThrow("Unexpected message received"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DOMException from "domexception";
|
||||
|
||||
global.DOMException = DOMException as typeof global.DOMException;
|
||||
|
||||
jest.mock("../src/http-api/utils", () => ({
|
||||
...jest.requireActual("../src/http-api/utils"),
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
|
||||
@@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
|
||||
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
|
||||
getIdentityServerUrl: jest.fn(),
|
||||
getHomeserverUrl: jest.fn(),
|
||||
getCapabilities: jest.fn().mockReturnValue({}),
|
||||
getCachedCapabilities: jest.fn().mockReturnValue({}),
|
||||
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
|
||||
@@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns ValidatedIssuerMetadata
|
||||
*/
|
||||
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
||||
export const mockOpenIdConfiguration = (
|
||||
issuer = "https://auth.org/",
|
||||
additionalGrantTypes: string[] = [],
|
||||
): ValidatedIssuerMetadata => ({
|
||||
issuer,
|
||||
revocation_endpoint: issuer + "revoke",
|
||||
token_endpoint: issuer + "token",
|
||||
@@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
|
||||
device_authorization_endpoint: issuer + "device",
|
||||
jwks_uri: issuer + "jwks",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
|
||||
@@ -50,17 +50,18 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
|
||||
});
|
||||
|
||||
it("Should encode unpadded URL-safe base64", () => {
|
||||
const toEncode = "?????";
|
||||
// Chosen to have padding and multiple instances of / and + in the base64
|
||||
const toEncode = "???????⊕⊗⊗";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const encoded = encodeUnpaddedBase64Url(data);
|
||||
expect(encoded).toEqual("Pz8_Pz8");
|
||||
expect(encoded).toEqual("Pz8_Pz8_P-KKleKKl-KKlw");
|
||||
});
|
||||
|
||||
it("Should decode URL-safe base64", () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8_P-KKleKKl-KKlw=="));
|
||||
|
||||
expect(decoded).toStrictEqual("?????");
|
||||
expect(decoded).toStrictEqual("???????⊕⊗⊗");
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", () => {
|
||||
|
||||
@@ -80,24 +80,22 @@ describe("ContentRepo", function () {
|
||||
it("should return an authenticated URL when requested", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, true, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
|
||||
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should force-enable allow_redirects when useAuthentication is set true", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, false, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
|
||||
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,16 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "../../../src";
|
||||
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
@@ -34,96 +25,175 @@ function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
it("rejects membership with no expiry and no expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(
|
||||
makeMockEvent(),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
|
||||
describe("CallMembershipDataLegacy", () => {
|
||||
const membershipTemplate: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit" }],
|
||||
};
|
||||
it("rejects membership with no expiry and no expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(
|
||||
makeMockEvent(),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("allow membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
}).toThrow();
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires_ts", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(1000),
|
||||
Object.assign({}, membershipTemplate, { expires_ts: 6000 }),
|
||||
);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.localTimestamp = Date.now() - 6000;
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
describe("SessionMembershipData", () => {
|
||||
const membershipTemplate: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
focus_active: { type: "livekit" },
|
||||
foci_preferred: [{ type: "livekit" }],
|
||||
};
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
it("allow membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires_ts", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(1000),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
|
||||
);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.localTimestamp = Date.now() - 6000;
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns active foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getActiveFoci()).toEqual([mockFocus]);
|
||||
it("returns preferred foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
const membershipTemplate: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit" }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
|
||||
|
||||
describe("LivekitFocus", () => {
|
||||
it("isLivekitFocus", () => {
|
||||
expect(
|
||||
isLivekitFocus({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
livekit_alias: "test",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusActive", () => {
|
||||
expect(
|
||||
isLivekitFocusActive({
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
it("isLivekitFocusConfig", () => {
|
||||
expect(
|
||||
isLivekitFocusConfig({
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://test.com",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
|
||||
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,11 @@ limitations under the License.
|
||||
|
||||
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import {
|
||||
CallMembershipData,
|
||||
CallMembershipDataLegacy,
|
||||
SessionMembershipData,
|
||||
} from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
@@ -29,6 +33,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
|
||||
};
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
@@ -98,22 +103,33 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const roomId = randomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId: randomString(8),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -122,22 +138,33 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const roomId = randomString(8);
|
||||
const event = {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
};
|
||||
const mockRoom = {
|
||||
...makeMockRoom([]),
|
||||
roomId: randomString(8),
|
||||
roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
getStateEvents: (_type: string, _stateKey: string) => [event],
|
||||
events: new Map([
|
||||
[
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
@@ -185,6 +212,67 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("updateCallMembershipEvent", () => {
|
||||
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||
const joinSessionConfig = { useLegacyMemberEvents: false };
|
||||
|
||||
const legacyMembershipData: CallMembershipDataLegacy = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_legacy",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [mockFocus],
|
||||
};
|
||||
|
||||
const expiredLegacyMembershipData: CallMembershipDataLegacy = {
|
||||
...legacyMembershipData,
|
||||
device_id: "AAAAAAA_legacy_expired",
|
||||
expires: 0,
|
||||
};
|
||||
|
||||
const sessionMembershipData: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_session",
|
||||
focus_active: mockFocus,
|
||||
foci_preferred: [mockFocus],
|
||||
};
|
||||
|
||||
function testSession(
|
||||
membershipData: CallMembershipData[] | SessionMembershipData,
|
||||
shouldUseLegacy: boolean,
|
||||
): void {
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
|
||||
|
||||
const makeNewLegacyMembershipsMock = jest.spyOn(sess as any, "makeNewLegacyMemberships");
|
||||
const makeNewMembershipMock = jest.spyOn(sess as any, "makeNewMembership");
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
|
||||
|
||||
expect(makeNewLegacyMembershipsMock).toHaveBeenCalledTimes(shouldUseLegacy ? 1 : 0);
|
||||
expect(makeNewMembershipMock).toHaveBeenCalledTimes(shouldUseLegacy ? 0 : 1);
|
||||
}
|
||||
|
||||
it("uses legacy events if there are any active legacy calls", () => {
|
||||
testSession([expiredLegacyMembershipData, legacyMembershipData, sessionMembershipData], true);
|
||||
});
|
||||
|
||||
it('uses legacy events if a non-legacy call is in a "memberships" array', () => {
|
||||
testSession([sessionMembershipData], true);
|
||||
});
|
||||
|
||||
it("uses non-legacy events if all legacy calls are expired", () => {
|
||||
testSession([expiredLegacyMembershipData], false);
|
||||
});
|
||||
|
||||
it("uses non-legacy events if there are only non-legacy calls", () => {
|
||||
testSession(sessionMembershipData, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
@@ -198,6 +286,64 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getsActiveFocus", () => {
|
||||
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "oldest_membership",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
});
|
||||
it("does not provide focus if the selction method is unknown", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
||||
type: "livekit",
|
||||
focus_selection: "unknown",
|
||||
});
|
||||
expect(sess.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
it("gets the correct active focus legacy", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "foo",
|
||||
created_ts: 500,
|
||||
foci_active: [activeFociConfig],
|
||||
}),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
|
||||
expect(sess.getActiveFocus()).toBe(activeFociConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
@@ -223,13 +369,13 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("shows joined once join is called", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", () => {
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
@@ -242,7 +388,8 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
expires_ts: Date.now() + 3600000,
|
||||
foci_active: [{ type: "mock" }],
|
||||
foci_active: [mockFocus],
|
||||
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
@@ -252,12 +399,48 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("non-legacy calls", () => {
|
||||
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
||||
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
|
||||
|
||||
function testJoin(useOwnedStateEvents: boolean): void {
|
||||
if (useOwnedStateEvents) {
|
||||
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3779.default");
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([activeFocusConfig], activeFocus, { useLegacyMemberEvents: false });
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
foci_preferred: [activeFocusConfig],
|
||||
focus_active: activeFocus,
|
||||
} satisfies SessionMembershipData,
|
||||
`${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`,
|
||||
);
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
it("sends a membership event with session payload when joining a non-legacy call", () => {
|
||||
testJoin(false);
|
||||
});
|
||||
|
||||
it("does not prefix the state key with _ for rooms that support user-owned state events", () => {
|
||||
testJoin(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -274,15 +457,26 @@ describe("MatrixRTCSession", () => {
|
||||
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
const eventContent = await eventSentPromise;
|
||||
|
||||
// definitely should have renewed by 1 second before the expiry!
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed));
|
||||
const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed);
|
||||
const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
getState.getStateEvents = jest.fn().mockReturnValue(event);
|
||||
getState.events = new Map([
|
||||
[
|
||||
event.getType(),
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
} as unknown as Map<string, MatrixEvent>,
|
||||
],
|
||||
]);
|
||||
|
||||
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
@@ -308,7 +502,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000 * 2,
|
||||
expires_ts: 1000 + 3600000 * 2,
|
||||
foci_active: [{ type: "mock" }],
|
||||
foci_active: [mockFocus],
|
||||
created_ts: 1000,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
@@ -322,7 +516,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("creates a key when joining", () => {
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
@@ -336,7 +530,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
@@ -372,7 +566,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
await eventSentPromise;
|
||||
@@ -394,7 +588,7 @@ describe("MatrixRTCSession", () => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
|
||||
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
|
||||
});
|
||||
@@ -409,7 +603,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
@@ -462,7 +656,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
|
||||
@@ -489,7 +683,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("Doesn't re-send key immediately", async () => {
|
||||
const realSetImmediate = setImmediate;
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
@@ -499,7 +693,7 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
@@ -517,7 +711,7 @@ describe("MatrixRTCSession", () => {
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
realSetImmediate(resolve);
|
||||
realSetTimeout(resolve);
|
||||
});
|
||||
|
||||
expect(sendEventMock).not.toHaveBeenCalled();
|
||||
@@ -595,7 +789,7 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
sess.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoomNoExpired!.roomId,
|
||||
@@ -631,7 +825,7 @@ describe("MatrixRTCSession", () => {
|
||||
]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
sess.joinRoomSession([mockFocus], mockFocus);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
@@ -645,6 +839,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 3600000,
|
||||
created_ts: 1000,
|
||||
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
foci_active: [{ type: "test" }],
|
||||
};
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
@@ -15,24 +15,27 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { CallMembershipData, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
|
||||
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
|
||||
type MembershipData = CallMembershipData[] | SessionMembershipData;
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room {
|
||||
const roomId = randomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(memberships, roomId, localAge);
|
||||
const roomState = makeMockRoomState(membershipData, roomId, localAge);
|
||||
return {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(memberships, roomId, localAge);
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(membershipData, roomId, localAge);
|
||||
return {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
@@ -40,15 +43,30 @@ export function makeMockRoomState(memberships: CallMembershipData[], roomId: str
|
||||
if (stateKey !== undefined) return event;
|
||||
return [event];
|
||||
},
|
||||
events: new Map([
|
||||
[
|
||||
event.getType(),
|
||||
{
|
||||
size: () => true,
|
||||
has: (_stateKey: string) => true,
|
||||
get: (_stateKey: string) => event,
|
||||
values: () => [event],
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
memberships: memberships,
|
||||
}),
|
||||
getContent: jest.fn().mockReturnValue(
|
||||
!Array.isArray(membershipData)
|
||||
? membershipData
|
||||
: {
|
||||
memberships: membershipData,
|
||||
},
|
||||
),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
localTimestamp: Date.now() - (localAge ?? 10),
|
||||
|
||||
+282
-240
@@ -61,251 +61,293 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
let httpBackend: MockHttpBackend;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async function () {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
httpBackend = new MockHttpBackend();
|
||||
/**
|
||||
* We need to split the tests into regular ones (these) and ones that use fake timers,
|
||||
* because the fake indexeddb uses timers too and appears make tests cause other tests
|
||||
* to fail if we keep enabling/disabling fake timers within the same test suite.
|
||||
*/
|
||||
describe("non-timed tests", () => {
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
jest.useRealTimers();
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
describe("async tests", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
let storeStarted = false;
|
||||
idbStore.startup().then(() => {
|
||||
storeStarted = true;
|
||||
});
|
||||
await flushAndRunTimersUntil(() => storeStarted);
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
Copyright 2024 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 { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src";
|
||||
import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature(feature: string) {
|
||||
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
baseUrl: "https://example.com",
|
||||
} as unknown as MatrixClient;
|
||||
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
|
||||
baseUrl: client.baseUrl,
|
||||
prefix: ClientPrefix.Unstable,
|
||||
onlyData: true,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
|
||||
describe("MSC4108RendezvousSession", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
async function postAndCheckLocation(msc4108Enabled: boolean, fallbackRzServer: string, locationResponse: string) {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled });
|
||||
const transport = new MSC4108RendezvousSession({ client, fallbackRzServer });
|
||||
{
|
||||
// initial POST
|
||||
const expectedPostLocation = msc4108Enabled
|
||||
? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous`
|
||||
: fallbackRzServer;
|
||||
|
||||
fetchMock.postOnce(expectedPostLocation, {
|
||||
status: 201,
|
||||
body: { url: locationResponse },
|
||||
});
|
||||
await transport.send("data");
|
||||
}
|
||||
|
||||
{
|
||||
fetchMock.get(locationResponse, {
|
||||
status: 200,
|
||||
body: "data",
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
"etag": "aaa",
|
||||
},
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("data");
|
||||
}
|
||||
}
|
||||
|
||||
it("should use custom fetchFn if provided", async () => {
|
||||
const sandbox = fetchMock.sandbox();
|
||||
const fetchFn = jest.fn().mockImplementation(sandbox);
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fetchFn,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
sandbox.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: {
|
||||
url: "https://fallbackserver/rz/123",
|
||||
},
|
||||
});
|
||||
await transport.send("data");
|
||||
await sandbox.flush(true);
|
||||
expect(fetchFn).toHaveBeenCalledWith("https://fallbackserver/rz", expect.anything());
|
||||
});
|
||||
|
||||
it("should throw an error when no server available", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({ client });
|
||||
await expect(transport.send("data")).rejects.toThrow("Invalid rendezvous URI");
|
||||
});
|
||||
|
||||
it("POST to fallback server", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await fetchMock.flush(true);
|
||||
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("POST with no location", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
});
|
||||
await Promise.all([expect(transport.send("data")).rejects.toThrow(), fetchMock.flush(true)]);
|
||||
});
|
||||
|
||||
it("POST with absolute path response", async function () {
|
||||
await postAndCheckLocation(false, "https://fallbackserver/rz", "https://fallbackserver/123");
|
||||
});
|
||||
|
||||
it("POST to built-in MSC3886 implementation", async function () {
|
||||
await postAndCheckLocation(
|
||||
true,
|
||||
"https://fallbackserver/rz",
|
||||
"https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("POST with relative path response including parent", async function () {
|
||||
await postAndCheckLocation(false, "https://fallbackserver/rz/abc", "https://fallbackserver/rz/xyz/123");
|
||||
});
|
||||
|
||||
// fetch-mock doesn't handle redirects properly, so we can't test this
|
||||
it.skip("POST to follow 307 to other server", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 307,
|
||||
redirectUrl: "https://redirected.fallbackserver/rz",
|
||||
redirected: true,
|
||||
});
|
||||
fetchMock.postOnce("https://redirected.fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://redirected.fallbackserver/rz/123" },
|
||||
headers: { etag: "aaa" },
|
||||
});
|
||||
await fetchMock.flush(true);
|
||||
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("POST and GET", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// first GET without etag
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", {
|
||||
status: 200,
|
||||
body: "foo=baa",
|
||||
headers: { "content-type": "text/plain", "etag": "aaa" },
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
{
|
||||
// subsequent GET which should have etag from previous request
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", {
|
||||
status: 200,
|
||||
body: "foo=baa",
|
||||
headers: { "content-type": "text/plain", "etag": "bbb" },
|
||||
});
|
||||
await expect(transport.receive()).resolves.toEqual("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
|
||||
method: "GET",
|
||||
headers: { "if-none-match": "aaa" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("POST and PUTs", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
headers: { etag: "aaa" },
|
||||
});
|
||||
await transport.send("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// subsequent PUT which should have etag from previous request
|
||||
fetchMock.putOnce("https://fallbackserver/rz/123", { status: 202, headers: { etag: "bbb" } });
|
||||
await transport.send("c=d");
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
|
||||
method: "PUT",
|
||||
headers: { "if-match": "aaa" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("POST and DELETE", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
{
|
||||
// Create
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
});
|
||||
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
|
||||
await fetchMock.flush(true);
|
||||
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
functionMatcher: (_, opts): boolean => {
|
||||
return opts.body === "foo=baa";
|
||||
},
|
||||
});
|
||||
}
|
||||
{
|
||||
// Cancel
|
||||
fetchMock.deleteOnce("https://fallbackserver/rz/123", { status: 204 });
|
||||
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("send after cancelled", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
|
||||
await expect(transport.send("data")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("receive before ready", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
});
|
||||
await expect(transport.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("404 failure callback", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const onFailure = jest.fn();
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
onFailure,
|
||||
});
|
||||
|
||||
fetchMock.postOnce("https://fallbackserver/rz", { status: 404 });
|
||||
await Promise.all([expect(transport.send("foo=baa")).resolves.toBeUndefined(), fetchMock.flush(true)]);
|
||||
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown);
|
||||
});
|
||||
|
||||
it("404 failure callback mapped to expired", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
|
||||
const onFailure = jest.fn();
|
||||
const transport = new MSC4108RendezvousSession({
|
||||
client,
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
onFailure,
|
||||
});
|
||||
|
||||
{
|
||||
// initial POST
|
||||
fetchMock.postOnce("https://fallbackserver/rz", {
|
||||
status: 201,
|
||||
body: { url: "https://fallbackserver/rz/123" },
|
||||
headers: { expires: "Thu, 01 Jan 1970 00:00:00 GMT" },
|
||||
});
|
||||
|
||||
await transport.send("foo=baa");
|
||||
await fetchMock.flush(true);
|
||||
}
|
||||
{
|
||||
// GET with 404 to simulate expiry
|
||||
fetchMock.getOnce("https://fallbackserver/rz/123", { status: 404, body: "foo=baa" });
|
||||
await Promise.all([expect(transport.receive()).resolves.toBeUndefined(), fetchMock.flush(true)]);
|
||||
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
Copyright 2024 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 { EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
|
||||
|
||||
describe("MSC4108SecureChannel", () => {
|
||||
const baseUrl = "https://example.com";
|
||||
const url = "https://fallbackserver/rz/123";
|
||||
|
||||
it("should generate qr code data as expected", async () => {
|
||||
const session = new MSC4108RendezvousSession({
|
||||
url,
|
||||
});
|
||||
const channel = new MSC4108SecureChannel(session);
|
||||
|
||||
const code = await channel.generateCode(QrCodeMode.Login);
|
||||
expect(code).toHaveLength(71);
|
||||
const text = new TextDecoder().decode(code);
|
||||
expect(text.startsWith("MATRIX")).toBeTruthy();
|
||||
expect(text.endsWith(url)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should throw error if attempt to connect multiple times", async () => {
|
||||
const mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await channel.connect();
|
||||
await expect(channel.connect()).rejects.toThrow("Channel already connected");
|
||||
});
|
||||
|
||||
it("should throw error on invalid initiate response", async () => {
|
||||
const mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
const channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue("");
|
||||
await expect(channel.connect()).rejects.toThrow("No response from other device");
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await expect(channel.connect()).rejects.toThrow("Invalid response from other device");
|
||||
});
|
||||
|
||||
describe("should be able to connect as a reciprocating device", () => {
|
||||
let mockSession: MSC4108RendezvousSession;
|
||||
let channel: MSC4108SecureChannel;
|
||||
let opponentChannel: EstablishedEcies;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockSession = {
|
||||
send: jest.fn(),
|
||||
receive: jest.fn(),
|
||||
url,
|
||||
} as unknown as MSC4108RendezvousSession;
|
||||
channel = new MSC4108SecureChannel(mockSession);
|
||||
|
||||
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
|
||||
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
|
||||
qrCodeData.publicKey,
|
||||
"MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
opponentChannel = _opponentChannel;
|
||||
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await channel.connect();
|
||||
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK");
|
||||
mocked(mockSession.send).mockReset();
|
||||
});
|
||||
|
||||
it("should be able to securely send encrypted payloads", async () => {
|
||||
const payload = {
|
||||
type: PayloadType.Secrets,
|
||||
protocols: ["a", "b", "c"],
|
||||
homeserver: "https://example.org",
|
||||
};
|
||||
await channel.secureSend(payload);
|
||||
expect(mockSession.send).toHaveBeenCalled();
|
||||
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
it("should be able to securely receive encrypted payloads", async () => {
|
||||
const payload = {
|
||||
type: PayloadType.Secrets,
|
||||
protocols: ["a", "b", "c"],
|
||||
homeserver: "https://example.org",
|
||||
};
|
||||
const ciphertext = opponentChannel.encrypt(JSON.stringify(payload));
|
||||
mocked(mockSession.receive).mockResolvedValue(ciphertext);
|
||||
await expect(channel.secureReceive()).resolves.toEqual(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
@@ -17,7 +17,12 @@ limitations under the License.
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import "../../olm-loader";
|
||||
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import {
|
||||
MSC3906Rendezvous,
|
||||
RendezvousCode,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
RendezvousIntent,
|
||||
} from "../../../src/rendezvous";
|
||||
import {
|
||||
ECDHv2RendezvousCode as ECDHRendezvousCode,
|
||||
MSC3903ECDHPayload,
|
||||
@@ -111,7 +116,7 @@ function makeMockClient(opts: {
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
getCachedCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { MatrixClient } from "../../../src";
|
||||
import { RendezvousFailureReason } from "../../../src/rendezvous";
|
||||
import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous";
|
||||
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
|
||||
|
||||
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {
|
||||
|
||||
@@ -4034,4 +4034,47 @@ describe("Room", function () {
|
||||
expect(room.getLastThread()).toBe(thread2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecommendedVersion", () => {
|
||||
it("returns the server's recommended version from capabilities", async () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.getCapabilities = jest.fn().mockReturnValue({
|
||||
["m.room_versions"]: {
|
||||
default: "1",
|
||||
available: ["1", "2"],
|
||||
},
|
||||
});
|
||||
const room = new Room(roomId, client, userA);
|
||||
expect(await room.getRecommendedVersion()).toEqual({
|
||||
version: "1",
|
||||
needsUpgrade: false,
|
||||
urgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
|
||||
const client = new TestClient(userA).client;
|
||||
client.getCapabilities = jest.fn().mockReturnValue({
|
||||
["m.room_versions"]: {
|
||||
default: "5",
|
||||
available: ["5"],
|
||||
},
|
||||
});
|
||||
|
||||
client.fetchCapabilities = jest.fn().mockResolvedValue({
|
||||
["m.room_versions"]: {
|
||||
default: "1",
|
||||
available: ["1"],
|
||||
},
|
||||
});
|
||||
|
||||
const room = new Room(roomId, client, userA);
|
||||
expect(await room.getRecommendedVersion()).toEqual({
|
||||
version: "1",
|
||||
needsUpgrade: false,
|
||||
urgent: false,
|
||||
});
|
||||
expect(client.fetchCapabilities).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1515,6 +1515,44 @@ describe("RustCrypto", () => {
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import & export secrets bundle", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if there is nothing to export", async () => {
|
||||
await expect(rustCrypto.exportSecretsBundle()).rejects.toThrow(
|
||||
"The store doesn't contain any cross-signing keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("should correctly import & export a secrets bundle", async () => {
|
||||
const bundle = {
|
||||
cross_signing: {
|
||||
master_key: "bMnVpkHI4S2wXRxy+IpaKM5PIAUUkl6DE+n0YLIW/qs",
|
||||
user_signing_key: "8tlgLjUrrb/zGJo4YKGhDTIDCEjtJTAS/Sh2AGNLuIo",
|
||||
self_signing_key: "pfDknmP5a0fVVRE54zhkUgJfzbNmvKcNfIWEW796bQs",
|
||||
},
|
||||
backup: {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
key: "bYYv3aFLQ49jMNcOjuTtBY9EKDby2x1m3gfX81nIKRQ",
|
||||
backup_version: "9",
|
||||
},
|
||||
};
|
||||
await rustCrypto.importSecretsBundle(bundle);
|
||||
await expect(rustCrypto.exportSecretsBundle()).resolves.toEqual(expect.objectContaining(bundle));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
+7
-3
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { NamespacedValue, UnstableValue } from "../NamespacedValue";
|
||||
import {
|
||||
PolicyRuleEventContent,
|
||||
RoomAvatarEventContent,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from "../webrtc/callEventTypes";
|
||||
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
|
||||
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
|
||||
import { SessionMembershipData } from "../matrixrtc/CallMembership";
|
||||
|
||||
export enum EventType {
|
||||
// Room state events
|
||||
@@ -302,7 +303,7 @@ export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matr
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new UnstableValue("membership", "io.element.msc4115.membership");
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new NamespacedValue("membership", "io.element.msc4115.membership");
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified non-state room events.
|
||||
@@ -356,7 +357,10 @@ export interface StateEvents {
|
||||
|
||||
// MSC3401
|
||||
[EventType.GroupCallPrefix]: IGroupCallRoomState;
|
||||
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
|
||||
[EventType.GroupCallMemberPrefix]: XOR<
|
||||
XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>,
|
||||
XOR<SessionMembershipData, {}>
|
||||
>;
|
||||
|
||||
// MSC3089
|
||||
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2024 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 type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
declare module "@matrix-org/matrix-sdk-crypto-wasm" {
|
||||
interface OlmMachine {
|
||||
importSecretsBundle(bundle: RustSdkCryptoJs.SecretsBundle): Promise<void>;
|
||||
exportSecretsBundle(): Promise<RustSdkCryptoJs.SecretsBundle>;
|
||||
}
|
||||
|
||||
interface SecretsBundle {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
to_json(): Promise<{
|
||||
cross_signing: {
|
||||
master_key: string;
|
||||
self_signing_key: string;
|
||||
user_signing_key: string;
|
||||
};
|
||||
backup?: {
|
||||
algorithm: string;
|
||||
key: string;
|
||||
backup_version: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ enum GroupKey {
|
||||
}
|
||||
|
||||
export interface IResultRoomEvents {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: ISearchResult[];
|
||||
count?: number;
|
||||
highlights?: string[];
|
||||
results?: ISearchResult[];
|
||||
state?: { [roomId: string]: IStateEventWithRoomId[] };
|
||||
groups?: {
|
||||
[groupKey in GroupKey]: {
|
||||
|
||||
@@ -95,7 +95,9 @@ export interface RoomTopicEventContent {
|
||||
|
||||
export interface RoomAvatarEventContent {
|
||||
url?: string;
|
||||
info?: ImageInfo;
|
||||
// The spec says that an encrypted file can be used for the thumbnail but this isn't true
|
||||
// https://github.com/matrix-org/matrix-spec/issues/562 so omit those fields
|
||||
info?: Omit<ImageInfo, "thumbnail_file">;
|
||||
}
|
||||
|
||||
export interface RoomPinnedEventsEventContent {
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): stri
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64Url(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeUnpaddedBase64(uint8Array).replace("+", "-").replace("/", "_");
|
||||
return encodeUnpaddedBase64(uint8Array).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ export function decodeBase64(base64: string): Uint8Array {
|
||||
const itFunc = function* (): Generator<number> {
|
||||
const decoded = atob(
|
||||
// built-in atob doesn't support base64url: convert so we support either
|
||||
base64.replace("-", "+").replace("_", "/"),
|
||||
base64.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
);
|
||||
for (let i = 0; i < decoded.length; ++i) {
|
||||
yield decoded.charCodeAt(i);
|
||||
|
||||
+47
-81
@@ -226,6 +226,7 @@ import { getRelationsThreadFilter } from "./thread-utils";
|
||||
import { KnownMembership, Membership } from "./@types/membership";
|
||||
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
|
||||
import { ImageInfo } from "./@types/media";
|
||||
import { Capabilities, ServerCapabilities } from "./serverCapabilities";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -233,7 +234,6 @@ export type ResetTimelineCallback = (roomId: string) => boolean;
|
||||
|
||||
const SCROLLBACK_DELAY_MS = 3000;
|
||||
export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
|
||||
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
|
||||
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
|
||||
|
||||
export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
|
||||
@@ -518,26 +518,6 @@ export interface IStartClientOpts {
|
||||
|
||||
export interface IStoredClientOpts extends IStartClientOpts {}
|
||||
|
||||
export enum RoomVersionStability {
|
||||
Stable = "stable",
|
||||
Unstable = "unstable",
|
||||
}
|
||||
|
||||
export interface IRoomVersionsCapability {
|
||||
default: string;
|
||||
available: Record<string, RoomVersionStability>;
|
||||
}
|
||||
|
||||
export interface ICapability {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IChangePasswordCapability extends ICapability {}
|
||||
|
||||
export interface IThreadsCapability extends ICapability {}
|
||||
|
||||
export interface IGetLoginTokenCapability extends ICapability {}
|
||||
|
||||
export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
|
||||
"m.get_login_token",
|
||||
"org.matrix.msc3882.get_login_token",
|
||||
@@ -547,19 +527,6 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
|
||||
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
|
||||
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
|
||||
|
||||
/**
|
||||
* A representation of the capabilities advertised by a homeserver as defined by
|
||||
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
|
||||
*/
|
||||
export interface Capabilities {
|
||||
[key: string]: any;
|
||||
"m.change_password"?: IChangePasswordCapability;
|
||||
"m.room_versions"?: IRoomVersionsCapability;
|
||||
"io.element.thread"?: IThreadsCapability;
|
||||
"m.get_login_token"?: IGetLoginTokenCapability;
|
||||
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
|
||||
}
|
||||
|
||||
enum CrossSigningKeyType {
|
||||
MasterKey = "master_key",
|
||||
SelfSigningKey = "self_signing_key",
|
||||
@@ -1293,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
|
||||
protected serverVersionsPromise?: Promise<IServerVersions>;
|
||||
|
||||
public cachedCapabilities?: {
|
||||
capabilities: Capabilities;
|
||||
expiration: number;
|
||||
};
|
||||
protected clientWellKnown?: IClientWellKnown;
|
||||
protected clientWellKnownPromise?: Promise<IClientWellKnown>;
|
||||
protected turnServers: ITurnServer[] = [];
|
||||
@@ -1325,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
public readonly matrixRTC: MatrixRTCSessionManager;
|
||||
|
||||
private serverCapabilitiesService: ServerCapabilities;
|
||||
|
||||
public constructor(opts: IMatrixClientCreateOpts) {
|
||||
super();
|
||||
|
||||
@@ -1418,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.http);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
this.timelineSupport = Boolean(opts.timelineSupport);
|
||||
@@ -1540,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.start();
|
||||
this.serverCapabilitiesService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1593,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.toDeviceMessageQueue.stop();
|
||||
|
||||
this.matrixRTC.stop();
|
||||
|
||||
this.serverCapabilitiesService.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2095,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the homeserver. Always returns an object of
|
||||
* capability keys and their options, which may be empty.
|
||||
* @param fresh - True to ignore any cached values.
|
||||
* @returns Promise which resolves to the capabilities of the homeserver
|
||||
* @returns Rejects: with an error response.
|
||||
* Gets the cached capabilities of the homeserver, returning cached ones if available.
|
||||
* If there are no cached capabilities and none can be fetched, throw an exception.
|
||||
*
|
||||
* @returns Promise resolving with The capabilities of the homeserver
|
||||
*/
|
||||
public getCapabilities(fresh = false): Promise<Capabilities> {
|
||||
const now = new Date().getTime();
|
||||
public async getCapabilities(): Promise<Capabilities> {
|
||||
const caps = this.serverCapabilitiesService.getCachedCapabilities();
|
||||
if (caps) return caps;
|
||||
return this.serverCapabilitiesService.fetchCapabilities();
|
||||
}
|
||||
|
||||
if (this.cachedCapabilities && !fresh) {
|
||||
if (now < this.cachedCapabilities.expiration) {
|
||||
this.logger.debug("Returning cached capabilities");
|
||||
return Promise.resolve(this.cachedCapabilities.capabilities);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Gets the cached capabilities of the homeserver. If none have been fetched yet,
|
||||
* return undefined.
|
||||
*
|
||||
* @returns The capabilities of the homeserver
|
||||
*/
|
||||
public getCachedCapabilities(): Capabilities | undefined {
|
||||
return this.serverCapabilitiesService.getCachedCapabilities();
|
||||
}
|
||||
|
||||
type Response = {
|
||||
capabilities?: Capabilities;
|
||||
};
|
||||
return this.http
|
||||
.authedRequest<Response>(Method.Get, "/capabilities")
|
||||
.catch((e: Error): Response => {
|
||||
// We swallow errors because we need a default object anyhow
|
||||
this.logger.error(e);
|
||||
return {};
|
||||
})
|
||||
.then((r = {}) => {
|
||||
const capabilities = r["capabilities"] || {};
|
||||
|
||||
// If the capabilities missed the cache, cache it for a shorter amount
|
||||
// of time to try and refresh them later.
|
||||
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
|
||||
|
||||
this.cachedCapabilities = {
|
||||
capabilities,
|
||||
expiration: now + cacheMs,
|
||||
};
|
||||
|
||||
this.logger.debug("Caching capabilities: ", capabilities);
|
||||
return capabilities;
|
||||
});
|
||||
/**
|
||||
* Fetches the latest capabilities from the homeserver, ignoring any cached
|
||||
* versions. The newly returned version is cached.
|
||||
*
|
||||
* @returns A promise which resolves to the capabilities of the homeserver
|
||||
*/
|
||||
public fetchCapabilities(): Promise<Capabilities> {
|
||||
return this.serverCapabilitiesService.fetchCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4319,9 +4277,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
|
||||
}
|
||||
|
||||
const queryString: Record<string, string | string[]> = {};
|
||||
let queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
queryString["server_name"] = opts.viaServers;
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
|
||||
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
|
||||
}
|
||||
}
|
||||
|
||||
const data: IJoinRequestBody = {};
|
||||
@@ -4331,7 +4293,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
// In case we were originally given an alias, check the room cache again
|
||||
@@ -4364,9 +4326,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
|
||||
|
||||
const queryParams: Record<string, string | string[]> = {};
|
||||
let queryParams: QueryDict = {};
|
||||
if (opts.viaServers) {
|
||||
queryParams.server_name = opts.viaServers;
|
||||
queryParams.via = opts.viaServers;
|
||||
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
|
||||
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
@@ -7109,7 +7075,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// append the new results to our existing results
|
||||
const resultsLength = roomEvents.results?.length ?? 0;
|
||||
for (let i = 0; i < resultsLength; i++) {
|
||||
const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
|
||||
const sr = SearchResult.fromJson(roomEvents.results![i], mapper);
|
||||
const room = this.getRoom(sr.context.getEvent().getRoomId());
|
||||
if (room) {
|
||||
// Copy over a known event sender if we can
|
||||
|
||||
+3
-5
@@ -62,7 +62,7 @@ export function getHttpUriForMxc(
|
||||
if (useAuthentication) {
|
||||
allowRedirects = true; // per docs (MSC3916 always expects redirects)
|
||||
|
||||
// Dev note: MSC3916 (as of writing) removes `allow_redirect` entirely, but
|
||||
// Dev note: MSC3916 removes `allow_redirect` entirely, but
|
||||
// for explicitness we set it here. This makes it slightly more obvious to
|
||||
// callers, hopefully.
|
||||
}
|
||||
@@ -70,8 +70,7 @@ export function getHttpUriForMxc(
|
||||
let serverAndMediaId = mxc.slice(6); // strips mxc://
|
||||
let prefix: string;
|
||||
if (useAuthentication) {
|
||||
// TODO: Use stable once available (requires FCP on MSC3916).
|
||||
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/download/";
|
||||
prefix = "/_matrix/client/v1/media/download/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/download/";
|
||||
}
|
||||
@@ -90,8 +89,7 @@ export function getHttpUriForMxc(
|
||||
// these are thumbnailing params so they probably want the
|
||||
// thumbnailing API...
|
||||
if (useAuthentication) {
|
||||
// TODO: Use stable once available (requires FCP on MSC3916).
|
||||
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/";
|
||||
prefix = "/_matrix/client/v1/media/thumbnail/";
|
||||
} else {
|
||||
prefix = "/_matrix/media/v3/thumbnail/";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
@@ -532,6 +533,23 @@ export interface CryptoApi {
|
||||
* to false.
|
||||
*/
|
||||
startDehydration(createNewKey?: boolean): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Import/export of secret keys
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Export secrets bundle for transmitting to another device as part of OIDC QR login
|
||||
*/
|
||||
exportSecretsBundle?(): Promise<Awaited<ReturnType<SecretsBundle["to_json"]>>>;
|
||||
|
||||
/**
|
||||
* Import secrets bundle transmitted from another device.
|
||||
* @param secrets - The secrets bundle received from the other device
|
||||
*/
|
||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum Feature {
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
RelationsRecursion = "RelationsRecursion",
|
||||
IntentionalMentions = "IntentionalMentions",
|
||||
MigrateServerNameToVia = "MigrateServerNameToVia",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -65,6 +66,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
|
||||
matrixVersion: "v1.7",
|
||||
},
|
||||
[Feature.MigrateServerNameToVia]: {
|
||||
unstablePrefixes: ["org.matrix.msc4156"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
|
||||
import { CryptoStore } from "./crypto/store/base";
|
||||
|
||||
export * from "./client";
|
||||
export * from "./serverCapabilities";
|
||||
export * from "./embedded";
|
||||
export * from "./http-api";
|
||||
export * from "./autodiscovery";
|
||||
|
||||
+136
-47
@@ -14,52 +14,114 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EitherAnd } from "matrix-events-sdk/lib/types";
|
||||
|
||||
import { MatrixEvent } from "../matrix";
|
||||
import { deepCompare } from "../utils";
|
||||
import { Focus } from "./focus";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus";
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
|
||||
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
|
||||
export interface CallMembershipData {
|
||||
application?: string;
|
||||
|
||||
// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
|
||||
|
||||
// MSC4143 (MatrixRTC) session membership data
|
||||
|
||||
export type SessionMembershipData = {
|
||||
application: string;
|
||||
call_id: string;
|
||||
device_id: string;
|
||||
|
||||
focus_active: Focus;
|
||||
foci_preferred: Focus[];
|
||||
created_ts?: number;
|
||||
|
||||
// Application specific data
|
||||
scope?: CallScope;
|
||||
};
|
||||
|
||||
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
|
||||
"focus_active" in data;
|
||||
|
||||
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = "Malformed session membership event: ";
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
|
||||
// optional parameters
|
||||
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
|
||||
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// Legacy session membership data
|
||||
|
||||
export type CallMembershipDataLegacy = {
|
||||
application: string;
|
||||
call_id: string;
|
||||
scope: CallScope;
|
||||
device_id: string;
|
||||
created_ts?: number;
|
||||
expires?: number;
|
||||
expires_ts?: number;
|
||||
foci_active?: Focus[];
|
||||
membershipID: string;
|
||||
}
|
||||
created_ts?: number;
|
||||
foci_active?: Focus[];
|
||||
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
|
||||
|
||||
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
|
||||
"membershipID" in data;
|
||||
|
||||
const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => {
|
||||
const prefix = "Malformed legacy rtc membership event: ";
|
||||
if (!("expires" in data || "expires_ts" in data)) {
|
||||
errors.push(prefix + "expires_ts or expires must be present");
|
||||
}
|
||||
if ("expires" in data) {
|
||||
if (typeof data.expires !== "number") {
|
||||
errors.push(prefix + "expires must be numeric");
|
||||
}
|
||||
}
|
||||
if ("expires_ts" in data) {
|
||||
if (typeof data.expires_ts !== "number") {
|
||||
errors.push(prefix + "expires_ts must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string");
|
||||
// optional elements
|
||||
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
|
||||
|
||||
export class CallMembership {
|
||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||
return deepCompare(a.data, b.data);
|
||||
return deepCompare(a.membershipData, b.membershipData);
|
||||
}
|
||||
private membershipData: CallMembershipData;
|
||||
|
||||
public constructor(
|
||||
private parentEvent: MatrixEvent,
|
||||
private data: CallMembershipData,
|
||||
data: any,
|
||||
) {
|
||||
if (!(data.expires || data.expires_ts)) {
|
||||
throw new Error("Malformed membership: expires_ts or expires must be present");
|
||||
const sessionErrors: string[] = [];
|
||||
const legacyErrors: string[] = [];
|
||||
if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) {
|
||||
throw Error(
|
||||
`unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`,
|
||||
);
|
||||
} else {
|
||||
this.membershipData = data;
|
||||
}
|
||||
if (data.expires) {
|
||||
if (typeof data.expires !== "number") {
|
||||
throw new Error("Malformed membership: expires must be numeric");
|
||||
}
|
||||
}
|
||||
if (data.expires_ts) {
|
||||
if (typeof data.expires_ts !== "number") {
|
||||
throw new Error("Malformed membership: expires_ts must be numeric");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
|
||||
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
|
||||
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
|
||||
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
|
||||
}
|
||||
|
||||
public get sender(): string | undefined {
|
||||
@@ -67,62 +129,89 @@ export class CallMembership {
|
||||
}
|
||||
|
||||
public get callId(): string {
|
||||
return this.data.call_id;
|
||||
return this.membershipData.call_id;
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
return this.data.device_id;
|
||||
return this.membershipData.device_id;
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.data.application;
|
||||
return this.membershipData.application;
|
||||
}
|
||||
|
||||
public get scope(): CallScope {
|
||||
return this.data.scope;
|
||||
public get scope(): CallScope | undefined {
|
||||
return this.membershipData.scope;
|
||||
}
|
||||
|
||||
public get membershipID(): string {
|
||||
return this.data.membershipID;
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID;
|
||||
// the createdTs behaves equivalent to the membershipID.
|
||||
// we only need the field for the legacy member envents where we needed to update them
|
||||
// synapse ignores sending state events if they have the same content.
|
||||
else return this.createdTs().toString();
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
return this.data.created_ts ?? this.parentEvent.getTs();
|
||||
return this.membershipData.created_ts ?? this.parentEvent.getTs();
|
||||
}
|
||||
|
||||
public getAbsoluteExpiry(): number {
|
||||
if (this.data.expires) {
|
||||
return this.createdTs() + this.data.expires;
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
return this.createdTs() + this.membershipData.expires;
|
||||
} else {
|
||||
// We know it exists because we checked for this in the constructor.
|
||||
return this.data.expires_ts!;
|
||||
return this.membershipData.expires_ts;
|
||||
}
|
||||
}
|
||||
|
||||
// gets the expiry time of the event, converted into the device's local time
|
||||
public getLocalExpiry(): number {
|
||||
if (this.data.expires) {
|
||||
public getLocalExpiry(): number | undefined {
|
||||
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
|
||||
if ("expires" in this.membershipData) {
|
||||
// we know createdTs exists since we already do the isLegacyCallMembershipData check
|
||||
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
|
||||
|
||||
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
|
||||
|
||||
return localCreationTs + this.data.expires;
|
||||
return localCreationTs + this.membershipData.expires;
|
||||
} else {
|
||||
// With expires_ts we cannot convert to local time.
|
||||
// TODO: Check the server timestamp and compute a diff to local time.
|
||||
return this.data.expires_ts!;
|
||||
return this.membershipData.expires_ts;
|
||||
}
|
||||
}
|
||||
|
||||
public getMsUntilExpiry(): number {
|
||||
return this.getLocalExpiry() - Date.now();
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
|
||||
}
|
||||
|
||||
public isExpired(): boolean {
|
||||
return this.getMsUntilExpiry() <= 0;
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
|
||||
|
||||
// MSC4143 events expire by being updated. So if the event exists, its not expired.
|
||||
return false;
|
||||
}
|
||||
|
||||
public getActiveFoci(): Focus[] {
|
||||
return this.data.foci_active ?? [];
|
||||
public getPreferredFoci(): Focus[] {
|
||||
// To support both, the new and the old MatrixRTC memberships have two cases based
|
||||
// on the availablitiy of `foci_preferred`
|
||||
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? [];
|
||||
|
||||
// MSC4143 style membership
|
||||
return this.membershipData.foci_preferred;
|
||||
}
|
||||
|
||||
public getFocusSelection(): string | undefined {
|
||||
if (isLegacyCallMembershipData(this.membershipData)) {
|
||||
return "oldest_membership";
|
||||
} else {
|
||||
const focusActive = this.membershipData.focus_active;
|
||||
if (isLivekitFocusActive(focusActive)) {
|
||||
return focusActive.focus_selection;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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 { Focus } from "./focus";
|
||||
|
||||
export interface LivekitFocusConfig extends Focus {
|
||||
type: "livekit";
|
||||
livekit_service_url: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
|
||||
object.type === "livekit" && "livekit_service_url" in object;
|
||||
|
||||
export interface LivekitFocus extends LivekitFocusConfig {
|
||||
livekit_alias: string;
|
||||
}
|
||||
|
||||
export const isLivekitFocus = (object: any): object is LivekitFocus =>
|
||||
isLivekitFocusConfig(object) && "livekit_alias" in object;
|
||||
|
||||
export interface LivekitFocusActive extends Focus {
|
||||
type: "livekit";
|
||||
focus_selection: "oldest_membership";
|
||||
}
|
||||
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
|
||||
object.type === "livekit" && "focus_selection" in object;
|
||||
@@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline";
|
||||
import { Room } from "../models/room";
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType } from "../@types/event";
|
||||
import { CallMembership, CallMembershipData } from "./CallMembership";
|
||||
import {
|
||||
CallMembership,
|
||||
CallMembershipData,
|
||||
CallMembershipDataLegacy,
|
||||
SessionMembershipData,
|
||||
isLegacyCallMembershipData,
|
||||
} from "./CallMembership";
|
||||
import { RoomStateEvent } from "../models/room-state";
|
||||
import { Focus } from "./focus";
|
||||
import { randomString, secureRandomBase64Url } from "../randomstring";
|
||||
@@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
|
||||
import { KnownMembership } from "../@types/membership";
|
||||
import { MatrixError } from "../http-api/errors";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus";
|
||||
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall";
|
||||
|
||||
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
|
||||
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
|
||||
@@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent {
|
||||
MembershipsChanged = "memberships_changed",
|
||||
// We joined or left the session: our own local idea of whether we are joined,
|
||||
// separate from MembershipsChanged, ie. independent of whether our member event
|
||||
// has succesfully gone through.
|
||||
// has successfully gone through.
|
||||
JoinStateChanged = "join_state_changed",
|
||||
// The key used to encrypt media has changed
|
||||
EncryptionKeyChanged = "encryption_key_changed",
|
||||
@@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = {
|
||||
participantId: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export interface JoinSessionConfig {
|
||||
/** If true, generate and share a media key for this participant,
|
||||
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
|
||||
* media keys for other participants become available.
|
||||
*/
|
||||
manageMediaKeys?: boolean;
|
||||
/** Lets you configure how the events for the session are formatted.
|
||||
* - legacy: use one event with a membership array.
|
||||
* - MSC4143: use one event per membership (with only one membership per event)
|
||||
* More details can be found in MSC4143 and by checking the types:
|
||||
* `CallMembershipDataLegacy` and `SessionMembershipData`
|
||||
*/
|
||||
useLegacyMemberEvents?: boolean;
|
||||
}
|
||||
/**
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
@@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
|
||||
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
private activeFoci: Focus[] | undefined;
|
||||
// This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
|
||||
private ownFocusActive?: Focus;
|
||||
// This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
|
||||
private ownFociPreferred?: Focus[];
|
||||
|
||||
private updateCallMembershipRunning = false;
|
||||
private needCallMembershipUpdate = false;
|
||||
|
||||
private manageMediaKeys = false;
|
||||
private useLegacyMemberEvents = true;
|
||||
// userId:deviceId => array of keys
|
||||
private encryptionKeys = new Map<string, Array<Uint8Array>>();
|
||||
private lastEncryptionKeyUpdateRequest?: number;
|
||||
@@ -134,21 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
|
||||
if (eventMemberships === undefined) {
|
||||
continue;
|
||||
const content = memberEvent.getContent();
|
||||
let membershipContents: any[] = [];
|
||||
// We first decide if its a MSC4143 event (per device state key)
|
||||
if ("memberships" in content) {
|
||||
// we have a legacy (one event for all devices) event
|
||||
if (!Array.isArray(content["memberships"])) {
|
||||
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
|
||||
continue;
|
||||
}
|
||||
membershipContents = content["memberships"];
|
||||
} else {
|
||||
// We have a MSC4143 event membership event
|
||||
if (Object.keys(content).length !== 0) {
|
||||
// We checked for empty content to not try to construct CallMembership's with {}.
|
||||
membershipContents.push(content);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(eventMemberships)) {
|
||||
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
|
||||
if (membershipContents.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const membershipData of eventMemberships) {
|
||||
for (const membershipData of membershipContents) {
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (membership.callId !== "" || membership.scope !== "m.room") {
|
||||
// for now, just ignore anything that isn't the a room scope call
|
||||
// for now, just ignore anything that isn't a room scope call
|
||||
logger.info(`Ignoring user-scoped call`);
|
||||
continue;
|
||||
}
|
||||
@@ -202,6 +239,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
* This is determined by checking if the relativeExpiry has been set.
|
||||
*/
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
@@ -232,30 +270,34 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* desired.
|
||||
* This method will return immediately and the session will be joined in the background.
|
||||
*
|
||||
* @param activeFoci - The list of foci to set as currently active in the call member event
|
||||
* @param manageMediaKeys - If true, generate and share a a media key for this participant,
|
||||
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
|
||||
* media keys for other participants become available.
|
||||
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
|
||||
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
|
||||
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
|
||||
* or optionally other room members homeserver well known.
|
||||
* @param joinConfig - Additional configuration for the joined session.
|
||||
*/
|
||||
public joinRoomSession(activeFoci: Focus[], manageMediaKeys?: boolean): void {
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${manageMediaKeys}`);
|
||||
this.activeFoci = activeFoci;
|
||||
this.ownFocusActive = fociActive;
|
||||
this.ownFociPreferred = fociPreferred;
|
||||
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
|
||||
this.manageMediaKeys = manageMediaKeys ?? false;
|
||||
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
this.useLegacyMemberEvents = joinConfig?.useLegacyMemberEvents ?? this.useLegacyMemberEvents;
|
||||
this.membershipId = randomString(5);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
if (manageMediaKeys) {
|
||||
|
||||
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${this.manageMediaKeys}`);
|
||||
if (joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestKeyEventSend();
|
||||
}
|
||||
// We don't wait for this, mostly because it may fail and schedule a retry, so this
|
||||
// function returning doesn't really mean anything at all.
|
||||
this.triggerCallMembershipEventUpdate();
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
logger.info(`Leaving call session in room ${this.room.roomId}`);
|
||||
this.relativeExpiry = undefined;
|
||||
this.activeFoci = undefined;
|
||||
this.ownFocusActive = undefined;
|
||||
this.manageMediaKeys = false;
|
||||
this.membershipId = undefined;
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
@@ -315,6 +357,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
});
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.ownFocusActive && isLivekitFocusActive(this.ownFocusActive)) {
|
||||
// A livekit active focus
|
||||
if (this.ownFocusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
if (!this.ownFocusActive) {
|
||||
// we use the legacy call.member events so default to oldest member
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
|
||||
return this.encryptionKeys.get(getParticipantId(userId, deviceId));
|
||||
}
|
||||
@@ -344,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* @param userId - The user ID of the participant
|
||||
* @param deviceId - Device ID of the participant
|
||||
* @param encryptionKeyIndex - The index of the key to set
|
||||
* @param encryptionKeyString - The string represenation of the key to set in base64
|
||||
* @param encryptionKeyString - The string representation of the key to set in base64
|
||||
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
|
||||
* encryption keys for the local participant to allow time for the key to
|
||||
* be distributed.
|
||||
@@ -379,7 +436,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
/**
|
||||
* Generate a new sender key and add it at the next available index
|
||||
* @param delayBeforeUse - If true, wait for a short period before settign the key for the
|
||||
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
|
||||
* media encryptor to use. If false, set the key immediately.
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): void {
|
||||
@@ -488,7 +545,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
let soonestExpiry;
|
||||
for (const membership of this.memberships) {
|
||||
const thisExpiry = membership.getMsUntilExpiry();
|
||||
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
|
||||
// If getMsUntilExpiry is undefined we have a MSC4143 (MatrixRTC) compliant event - it never expires
|
||||
// but will be reliably resent on disconnect.
|
||||
if (thisExpiry !== undefined && (soonestExpiry === undefined || thisExpiry < soonestExpiry)) {
|
||||
soonestExpiry = thisExpiry;
|
||||
}
|
||||
}
|
||||
@@ -502,6 +561,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
return this.memberships[0];
|
||||
}
|
||||
|
||||
public getFocusInUse(): Focus | undefined {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
|
||||
return oldestMembership.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
public onCallEncryption = (event: MatrixEvent): void => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
@@ -613,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* Constructs our own membership
|
||||
* @param prevMembership - The previous value of our call membership, if any
|
||||
*/
|
||||
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
|
||||
private makeMyMembershipLegacy(deviceId: string, prevMembership?: CallMembership): CallMembershipDataLegacy {
|
||||
if (this.relativeExpiry === undefined) {
|
||||
throw new Error("Tried to create our own membership event when we're not joined!");
|
||||
}
|
||||
if (this.membershipId === undefined) {
|
||||
throw new Error("Tried to create our own membership event when we have no membership ID!");
|
||||
}
|
||||
|
||||
const m: CallMembershipData = {
|
||||
const createdTs = prevMembership?.createdTs();
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.client.getDeviceId()!,
|
||||
device_id: deviceId,
|
||||
expires: this.relativeExpiry,
|
||||
foci_active: this.activeFoci,
|
||||
// TODO: Date.now() should be the origin_server_ts (now).
|
||||
expires_ts: this.relativeExpiry + (createdTs ?? Date.now()),
|
||||
// we use the fociPreferred since this is the list of foci.
|
||||
// it is named wrong in the Legacy events.
|
||||
foci_active: this.ownFociPreferred,
|
||||
membershipID: this.membershipId,
|
||||
...(createdTs ? { created_ts: createdTs } : {}),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(deviceId: string): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: deviceId,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.ownFociPreferred ?? [],
|
||||
};
|
||||
|
||||
if (prevMembership) m.created_ts = prevMembership.createdTs();
|
||||
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
|
||||
// TODO: Date.now() should be the origin_server_ts (now).
|
||||
else m.expires_ts = Date.now() + (m.expires ?? 0);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,36 +723,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
myPrevMembershipData?: CallMembershipData,
|
||||
myPrevMembership?: CallMembership,
|
||||
): boolean {
|
||||
// work out if we need to update our membership event
|
||||
let needsUpdate = false;
|
||||
if (myPrevMembership && myPrevMembership.getMsUntilExpiry() === undefined) return false;
|
||||
|
||||
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
|
||||
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
|
||||
if (this.isJoined()) {
|
||||
// ...or if we are joined, but there's no valid membership event
|
||||
if (!myPrevMembership) {
|
||||
needsUpdate = true;
|
||||
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
|
||||
// ...or if the expiry time needs bumping
|
||||
needsUpdate = true;
|
||||
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
|
||||
}
|
||||
if (!this.isJoined()) return !!myPrevMembershipData;
|
||||
|
||||
// ...or if we are joined, but there's no valid membership event
|
||||
if (!myPrevMembership) return true;
|
||||
|
||||
const expiryTime = myPrevMembership.getMsUntilExpiry();
|
||||
if (expiryTime !== undefined && expiryTime < MEMBERSHIP_EXPIRY_TIME / 2) {
|
||||
// ...or if the expiry time needs bumping
|
||||
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
|
||||
return true;
|
||||
}
|
||||
|
||||
return needsUpdate;
|
||||
return false;
|
||||
}
|
||||
|
||||
private makeNewMembership(deviceId: string): SessionMembershipData | {} {
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
return this.makeMyMembership(deviceId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
/**
|
||||
* Makes a new membership list given the old list alonng with this user's previous membership event
|
||||
* (if any) and this device's previous membership (if any)
|
||||
*/
|
||||
private makeNewMemberships(
|
||||
private makeNewLegacyMemberships(
|
||||
oldMemberships: CallMembershipData[],
|
||||
localDeviceId: string,
|
||||
myCallMemberEvent?: MatrixEvent,
|
||||
myPrevMembership?: CallMembership,
|
||||
): CallMembershipData[] {
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localDeviceId) throw new Error("Local device ID is null!");
|
||||
|
||||
): ExperimentalGroupCallRoomMemberState {
|
||||
const filterExpired = (m: CallMembershipData): boolean => {
|
||||
let membershipObj;
|
||||
try {
|
||||
@@ -704,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
newMemberships.push(this.makeMyMembership(myPrevMembership));
|
||||
newMemberships.push(this.makeMyMembershipLegacy(localDeviceId, myPrevMembership));
|
||||
}
|
||||
|
||||
return newMemberships;
|
||||
return { memberships: newMemberships };
|
||||
}
|
||||
|
||||
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
|
||||
@@ -741,47 +823,58 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
|
||||
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
|
||||
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
|
||||
|
||||
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
|
||||
let myPrevMembership;
|
||||
try {
|
||||
if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) {
|
||||
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
|
||||
const callMemberEvents = roomState.events.get(EventType.GroupCallMemberPrefix);
|
||||
const legacy =
|
||||
!!this.useLegacyMemberEvents ||
|
||||
(callMemberEvents?.size && this.stateEventsContainOngoingLegacySession(callMemberEvents));
|
||||
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
|
||||
if (legacy) {
|
||||
const myCallMemberEvent = callMemberEvents?.get(localUserId);
|
||||
const content = myCallMemberEvent?.getContent() ?? {};
|
||||
let myPrevMembership: CallMembership | undefined;
|
||||
// We know its CallMembershipDataLegacy
|
||||
const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"])
|
||||
? content["memberships"]
|
||||
: [];
|
||||
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
|
||||
try {
|
||||
if (
|
||||
myCallMemberEvent &&
|
||||
myPrevMembershipData &&
|
||||
isLegacyCallMembershipData(myPrevMembershipData) &&
|
||||
myPrevMembershipData.membershipID === this.membershipId
|
||||
) {
|
||||
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
|
||||
}
|
||||
} catch (e) {
|
||||
// This would indicate a bug or something weird if our own call membership
|
||||
// wasn't valid
|
||||
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
|
||||
}
|
||||
} catch (e) {
|
||||
// This would indicate a bug or something weird if our own call membership
|
||||
// wasn't valid
|
||||
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
|
||||
if (myPrevMembership) {
|
||||
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
|
||||
}
|
||||
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
|
||||
// nothing to do - reschedule the check again
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
return;
|
||||
}
|
||||
newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership);
|
||||
} else {
|
||||
newContent = this.makeNewMembership(localDeviceId);
|
||||
}
|
||||
|
||||
if (myPrevMembership) {
|
||||
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
|
||||
}
|
||||
|
||||
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
|
||||
// nothing to do - reschedule the check again
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = {
|
||||
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
newContent,
|
||||
localUserId,
|
||||
legacy ? localUserId : this.makeMembershipStateKey(localUserId, localDeviceId),
|
||||
);
|
||||
logger.info(`Sent updated call member event.`);
|
||||
|
||||
// check periodically to see if we need to refresh our member event
|
||||
if (this.isJoined()) {
|
||||
if (this.isJoined() && legacy) {
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -792,6 +885,29 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent>): boolean {
|
||||
for (const callMemberEvent of callMemberEvents.values()) {
|
||||
const content = callMemberEvent.getContent();
|
||||
if (Array.isArray(content["memberships"])) {
|
||||
for (const membership of content.memberships) {
|
||||
if (!new CallMembership(callMemberEvent, membership).isExpired()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc3779\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
private onRotateKeyTimeout = (): void => {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.removeListener(ClientEvent.Room, this.onRoom);
|
||||
this.client.removeListener(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomState);
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,4 +21,5 @@ limitations under the License.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
+2
-7
@@ -77,7 +77,6 @@ export interface IUnsigned {
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]?: Membership | string;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
@@ -717,13 +716,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @returns The user's room membership, or `undefined` if the server does
|
||||
* not report it.
|
||||
*/
|
||||
public getMembershipAtEvent(): Membership | string | undefined {
|
||||
public getMembershipAtEvent(): Optional<Membership | string> {
|
||||
const unsigned = this.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_MEMBERSHIP_FIELD.name] === "string") {
|
||||
return unsigned[UNSIGNED_MEMBERSHIP_FIELD.name];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+17
-5
@@ -41,7 +41,7 @@ import {
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
|
||||
import { MatrixClient, PendingEventOrdering } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
|
||||
import { Filter, IFilterDefinition } from "../filter";
|
||||
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
|
||||
@@ -70,6 +70,7 @@ import { RoomReceipts } from "./room-receipts";
|
||||
import { compareEventOrdering } from "./compare-event-ordering";
|
||||
import * as utils from "../utils";
|
||||
import { KnownMembership, Membership } from "../@types/membership";
|
||||
import { Capabilities, IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities";
|
||||
|
||||
// These constants are used as sane defaults when the homeserver doesn't support
|
||||
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
|
||||
@@ -397,7 +398,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
|
||||
*/
|
||||
public currentState!: RoomState;
|
||||
public readonly relations = new RelationsContainer(this.client, this);
|
||||
|
||||
public readonly relations;
|
||||
|
||||
/**
|
||||
* A collection of events known by the client
|
||||
@@ -459,6 +461,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
private readonly opts: IOpts = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
// In some cases, we add listeners for every displayed Matrix event, so it's
|
||||
// common to have quite a few more than the default limit.
|
||||
this.setMaxListeners(100);
|
||||
@@ -469,6 +472,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
this.name = roomId;
|
||||
this.normalizedName = roomId;
|
||||
|
||||
this.relations = new RelationsContainer(this.client, this);
|
||||
|
||||
// Listen to our own receipt event as a more modular way of processing our own
|
||||
// receipts. No need to remove the listener: it's on ourself anyway.
|
||||
this.on(RoomEvent.Receipt, this.onReceipt);
|
||||
@@ -611,7 +616,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Resolves to the version the room should be upgraded to.
|
||||
*/
|
||||
public async getRecommendedVersion(): Promise<IRecommendedVersion> {
|
||||
const capabilities = await this.client.getCapabilities();
|
||||
let capabilities: Capabilities = {};
|
||||
try {
|
||||
capabilities = await this.client.getCapabilities();
|
||||
} catch (e) {}
|
||||
let versionCap = capabilities["m.room_versions"];
|
||||
if (!versionCap) {
|
||||
versionCap = {
|
||||
@@ -636,8 +644,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
"to be supporting a newer room version we don't know about.",
|
||||
);
|
||||
|
||||
const caps = await this.client.getCapabilities(true);
|
||||
versionCap = caps["m.room_versions"];
|
||||
try {
|
||||
capabilities = await this.client.fetchCapabilities();
|
||||
} catch (e) {
|
||||
logger.warn("Failed to refresh room version capabilities", e);
|
||||
}
|
||||
versionCap = capabilities["m.room_versions"];
|
||||
if (!versionCap) {
|
||||
logger.warn("No room version capability - assuming upgrade required.");
|
||||
return result;
|
||||
|
||||
@@ -49,6 +49,8 @@ interface OidcRegistrationRequestBody {
|
||||
application_type: "web" | "native";
|
||||
}
|
||||
|
||||
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
|
||||
@@ -16,13 +16,18 @@ limitations under the License.
|
||||
|
||||
import { UnstableValue } from "matrix-events-sdk";
|
||||
|
||||
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
|
||||
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
|
||||
import {
|
||||
RendezvousChannel,
|
||||
RendezvousFailureListener,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
RendezvousIntent,
|
||||
} from ".";
|
||||
import { MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
||||
import { logger } from "../logger";
|
||||
import { sleep } from "../utils";
|
||||
import { CrossSigningKey } from "../crypto-api";
|
||||
import { Device } from "../matrix";
|
||||
import { Capabilities, Device, IGetLoginTokenCapability } from "../matrix";
|
||||
|
||||
enum PayloadType {
|
||||
Start = "m.login.start",
|
||||
@@ -59,6 +64,9 @@ const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc390
|
||||
* Implements MSC3906 to allow a user to sign in on a new device using QR code.
|
||||
* This implementation only supports generating a QR code on a device that is already signed in.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3906Rendezvous {
|
||||
private newDeviceId?: string;
|
||||
@@ -101,7 +109,10 @@ export class MSC3906Rendezvous {
|
||||
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
|
||||
|
||||
// in stable and unstable r1 the availability is exposed as a capability
|
||||
const capabilities = await this.client.getCapabilities();
|
||||
let capabilities: Capabilities = {};
|
||||
try {
|
||||
capabilities = await this.client.getCapabilities();
|
||||
} catch (e) {}
|
||||
// in r0 of MSC3882 the availability is exposed as a feature flag
|
||||
const features = await buildFeatureSupportMap(await this.client.getVersions());
|
||||
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
Copyright 2024 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 { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener } from ".";
|
||||
import { MatrixClient } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel";
|
||||
import { MatrixError } from "../http-api";
|
||||
import { sleep } from "../utils";
|
||||
import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
|
||||
/**
|
||||
* Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
|
||||
* secure channels.
|
||||
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
*/
|
||||
export enum PayloadType {
|
||||
Protocols = "m.login.protocols",
|
||||
Protocol = "m.login.protocol",
|
||||
Failure = "m.login.failure",
|
||||
Success = "m.login.success",
|
||||
Secrets = "m.login.secrets",
|
||||
ProtocolAccepted = "m.login.protocol_accepted",
|
||||
Declined = "m.login.declined",
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the base payload format for [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
|
||||
* messages sent over the secure channel.
|
||||
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
*/
|
||||
export interface MSC4108Payload {
|
||||
type: PayloadType;
|
||||
}
|
||||
|
||||
interface ProtocolsPayload extends MSC4108Payload {
|
||||
type: PayloadType.Protocols;
|
||||
protocols: string[];
|
||||
homeserver: string;
|
||||
}
|
||||
|
||||
interface ProtocolPayload extends MSC4108Payload {
|
||||
type: PayloadType.Protocol;
|
||||
protocol: Exclude<string, "device_authorization_grant">;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload {
|
||||
protocol: "device_authorization_grant";
|
||||
device_authorization_grant: {
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isDeviceAuthorizationGrantProtocolPayload(
|
||||
payload: ProtocolPayload,
|
||||
): payload is DeviceAuthorizationGrantProtocolPayload {
|
||||
return payload.protocol === "device_authorization_grant";
|
||||
}
|
||||
|
||||
interface FailurePayload extends MSC4108Payload {
|
||||
type: PayloadType.Failure;
|
||||
reason: MSC4108FailureReason;
|
||||
homeserver?: string;
|
||||
}
|
||||
|
||||
interface DeclinedPayload extends MSC4108Payload {
|
||||
type: PayloadType.Declined;
|
||||
}
|
||||
|
||||
interface SuccessPayload extends MSC4108Payload {
|
||||
type: PayloadType.Success;
|
||||
}
|
||||
|
||||
interface AcceptedPayload extends MSC4108Payload {
|
||||
type: PayloadType.ProtocolAccepted;
|
||||
}
|
||||
|
||||
interface SecretsPayload extends MSC4108Payload, Awaited<ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>>> {
|
||||
type: PayloadType.Secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
|
||||
* sign in with QR + OIDC flow.
|
||||
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
*/
|
||||
export class MSC4108SignInWithQR {
|
||||
private readonly ourIntent: QrCodeMode;
|
||||
private _code?: Uint8Array;
|
||||
private expectingNewDeviceId?: string;
|
||||
|
||||
/**
|
||||
* Returns the check code for the secure channel or undefined if not generated yet.
|
||||
*/
|
||||
public get checkCode(): string | undefined {
|
||||
return this.channel?.getCheckCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param channel - The secure channel used for communication
|
||||
* @param client - The Matrix client in used on the device already logged in
|
||||
* @param didScanCode - Whether this side of the channel scanned the QR code from the other party
|
||||
* @param onFailure - Callback for when the rendezvous fails
|
||||
*/
|
||||
public constructor(
|
||||
private readonly channel: MSC4108SecureChannel,
|
||||
private readonly didScanCode: boolean,
|
||||
private readonly client?: MatrixClient,
|
||||
public onFailure?: RendezvousFailureListener,
|
||||
) {
|
||||
this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
|
||||
*/
|
||||
public get code(): Uint8Array | undefined {
|
||||
return this._code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the code including doing partial set up of the channel where required.
|
||||
*/
|
||||
public async generateCode(): Promise<void> {
|
||||
if (this._code) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
|
||||
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain()!);
|
||||
} else if (this.ourIntent === QrCodeMode.Login) {
|
||||
this._code = await this.channel.generateCode(this.ourIntent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel.
|
||||
*/
|
||||
public get isExistingDevice(): boolean {
|
||||
return this.ourIntent === QrCodeMode.Reciprocate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the device is the new device logging in being reciprocated by the device on the other side of the channel.
|
||||
*/
|
||||
public get isNewDevice(): boolean {
|
||||
return !this.isExistingDevice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first step in the OIDC QR login process.
|
||||
* To be called after the QR code has been rendered or scanned.
|
||||
* The scanning device has to discover the homeserver details, if they scanned the code then they already have it.
|
||||
* If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel.
|
||||
*/
|
||||
public async negotiateProtocols(): Promise<{ serverName?: string }> {
|
||||
logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`);
|
||||
await this.channel.connect();
|
||||
|
||||
if (this.didScanCode) {
|
||||
// Secure Channel step 6 completed, we trust the channel
|
||||
|
||||
if (this.isNewDevice) {
|
||||
// MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set
|
||||
} else {
|
||||
// MSC4108-Flow: NewScanned -send protocols message
|
||||
let oidcClientConfig: OidcClientConfig | undefined;
|
||||
try {
|
||||
const { issuer } = await this.client!.getAuthIssuer();
|
||||
oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer);
|
||||
} catch (e) {
|
||||
logger.error("Failed to discover OIDC metadata", e);
|
||||
}
|
||||
|
||||
if (oidcClientConfig?.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) {
|
||||
await this.send<ProtocolsPayload>({
|
||||
type: PayloadType.Protocols,
|
||||
protocols: ["device_authorization_grant"],
|
||||
homeserver: this.client!.getDomain()!,
|
||||
});
|
||||
} else {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnsupportedProtocol,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Device code grant unsupported",
|
||||
MSC4108FailureReason.UnsupportedProtocol,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (this.isNewDevice) {
|
||||
// MSC4108-Flow: ExistingScanned - wait for protocols message
|
||||
logger.info("Waiting for protocols message");
|
||||
const payload = await this.receive<ProtocolsPayload>();
|
||||
|
||||
if (payload?.type === PayloadType.Failure) {
|
||||
throw new RendezvousError("Failed", payload.reason);
|
||||
}
|
||||
|
||||
if (payload?.type !== PayloadType.Protocols) {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Unexpected message received",
|
||||
MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
);
|
||||
}
|
||||
|
||||
return { serverName: payload.homeserver };
|
||||
} else {
|
||||
// MSC4108-Flow: NewScanned - nothing to do
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The second & third step in the OIDC QR login process.
|
||||
* To be called after `negotiateProtocols` for the existing device.
|
||||
* To be called after OIDC negotiation for the new device. (Currently unsupported)
|
||||
*/
|
||||
public async deviceAuthorizationGrant(): Promise<{
|
||||
verificationUri?: string;
|
||||
userCode?: string;
|
||||
}> {
|
||||
if (this.isNewDevice) {
|
||||
throw new Error("New device flows around OIDC are not yet implemented");
|
||||
} else {
|
||||
// The user needs to do step 7 for the out-of-band confirmation
|
||||
// but, first we receive the protocol chosen by the other device so that
|
||||
// the confirmation_uri is ready to go
|
||||
logger.info("Waiting for protocol message");
|
||||
const payload = await this.receive<ProtocolPayload | DeviceAuthorizationGrantProtocolPayload>();
|
||||
|
||||
if (payload?.type === PayloadType.Failure) {
|
||||
throw new RendezvousError("Failed", payload.reason);
|
||||
}
|
||||
|
||||
if (payload?.type !== PayloadType.Protocol) {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Unexpected message received",
|
||||
MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDeviceAuthorizationGrantProtocolPayload(payload)) {
|
||||
const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = payload;
|
||||
const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag;
|
||||
|
||||
let deviceAlreadyExists = true;
|
||||
try {
|
||||
await this.client?.getDevice(expectingNewDeviceId);
|
||||
} catch (err: MatrixError | unknown) {
|
||||
if (err instanceof MatrixError && err.httpStatus === 404) {
|
||||
deviceAlreadyExists = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceAlreadyExists) {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.DeviceAlreadyExists,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Specified device ID already exists",
|
||||
MSC4108FailureReason.DeviceAlreadyExists,
|
||||
);
|
||||
}
|
||||
|
||||
this.expectingNewDeviceId = expectingNewDeviceId;
|
||||
|
||||
return { verificationUri: verificationUriComplete ?? verificationUri };
|
||||
}
|
||||
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnsupportedProtocol,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Received a request for an unsupported protocol",
|
||||
MSC4108FailureReason.UnsupportedProtocol,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fifth (and final) step in the OIDC QR login process.
|
||||
* To be called after the new device has completed authentication.
|
||||
*/
|
||||
public async shareSecrets(): Promise<{ secrets?: Omit<SecretsPayload, "type"> }> {
|
||||
if (this.isNewDevice) {
|
||||
await this.send<SuccessPayload>({
|
||||
type: PayloadType.Success,
|
||||
});
|
||||
// then wait for secrets
|
||||
logger.info("Waiting for secrets message");
|
||||
const payload = await this.receive<SecretsPayload>();
|
||||
if (payload?.type === PayloadType.Failure) {
|
||||
throw new RendezvousError("Failed", payload.reason);
|
||||
}
|
||||
|
||||
if (payload?.type !== PayloadType.Secrets) {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
});
|
||||
throw new RendezvousError(
|
||||
"Unexpected message received",
|
||||
MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
);
|
||||
}
|
||||
return { secrets: payload };
|
||||
// then done?
|
||||
} else {
|
||||
if (!this.expectingNewDeviceId) {
|
||||
throw new Error("No new device ID expected");
|
||||
}
|
||||
await this.send<AcceptedPayload>({
|
||||
type: PayloadType.ProtocolAccepted,
|
||||
});
|
||||
|
||||
logger.info("Waiting for outcome message");
|
||||
const payload = await this.receive<SuccessPayload | DeclinedPayload>();
|
||||
|
||||
if (payload?.type === PayloadType.Failure) {
|
||||
throw new RendezvousError("Failed", payload.reason);
|
||||
}
|
||||
|
||||
if (payload?.type === PayloadType.Declined) {
|
||||
throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined);
|
||||
}
|
||||
|
||||
if (payload?.type !== PayloadType.Success) {
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
});
|
||||
throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived);
|
||||
}
|
||||
|
||||
const timeout = Date.now() + 10000; // wait up to 10 seconds
|
||||
do {
|
||||
// is the device visible via the Homeserver?
|
||||
try {
|
||||
const device = await this.client?.getDevice(this.expectingNewDeviceId);
|
||||
|
||||
if (device) {
|
||||
// if so, return the secrets
|
||||
const secretsBundle = await this.client!.getCrypto()!.exportSecretsBundle!();
|
||||
if (this.channel.cancelled) {
|
||||
throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled);
|
||||
}
|
||||
// send secrets
|
||||
await this.send<SecretsPayload>({
|
||||
type: PayloadType.Secrets,
|
||||
...secretsBundle,
|
||||
});
|
||||
return { secrets: secretsBundle };
|
||||
// let the other side close the rendezvous session
|
||||
}
|
||||
} catch (err: MatrixError | unknown) {
|
||||
if (err instanceof MatrixError && err.httpStatus === 404) {
|
||||
// not found, so keep waiting until timeout
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await sleep(1000);
|
||||
} while (Date.now() < timeout);
|
||||
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.DeviceNotFound,
|
||||
});
|
||||
throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
private async receive<T extends MSC4108Payload>(): Promise<T | FailurePayload | undefined> {
|
||||
return (await this.channel.secureReceive()) as T | undefined;
|
||||
}
|
||||
|
||||
private async send<T extends MSC4108Payload>(payload: T): Promise<void> {
|
||||
await this.channel.secureSend(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline the login on the existing device.
|
||||
*/
|
||||
public async declineLoginOnExistingDevice(): Promise<void> {
|
||||
if (!this.isExistingDevice) {
|
||||
throw new Error("Can only decline login on existing device");
|
||||
}
|
||||
await this.send<FailurePayload>({
|
||||
type: PayloadType.Failure,
|
||||
reason: MSC4108FailureReason.UserCancelled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the rendezvous session.
|
||||
* @param reason the reason for the cancellation
|
||||
*/
|
||||
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
|
||||
this.onFailure?.(reason);
|
||||
await this.channel.cancel(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the rendezvous session.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
await this.channel.close();
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,47 @@ limitations under the License.
|
||||
|
||||
export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void;
|
||||
|
||||
export enum RendezvousFailureReason {
|
||||
export type RendezvousFailureReason =
|
||||
| LegacyRendezvousFailureReason
|
||||
| MSC4108FailureReason
|
||||
| ClientRendezvousFailureReason;
|
||||
|
||||
export enum LegacyRendezvousFailureReason {
|
||||
UserDeclined = "user_declined",
|
||||
OtherDeviceNotSignedIn = "other_device_not_signed_in",
|
||||
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
|
||||
Unknown = "unknown",
|
||||
Expired = "expired",
|
||||
UserCancelled = "user_cancelled",
|
||||
InvalidCode = "invalid_code",
|
||||
UnsupportedAlgorithm = "unsupported_algorithm",
|
||||
DataMismatch = "data_mismatch",
|
||||
UnsupportedTransport = "unsupported_transport",
|
||||
UnsupportedProtocol = "unsupported_protocol",
|
||||
HomeserverLacksSupport = "homeserver_lacks_support",
|
||||
}
|
||||
|
||||
export enum MSC4108FailureReason {
|
||||
AuthorizationExpired = "authorization_expired",
|
||||
DeviceAlreadyExists = "device_already_exists",
|
||||
DeviceNotFound = "device_not_found",
|
||||
UnexpectedMessageReceived = "unexpected_message_received",
|
||||
UnsupportedProtocol = "unsupported_protocol",
|
||||
UserCancelled = "user_cancelled",
|
||||
}
|
||||
|
||||
export enum ClientRendezvousFailureReason {
|
||||
/** The sign in request has expired */
|
||||
Expired = "expired",
|
||||
/** The homeserver is lacking support for the required features */
|
||||
HomeserverLacksSupport = "homeserver_lacks_support",
|
||||
/** The secure channel verification failed meaning that it might be compromised */
|
||||
InsecureChannelDetected = "insecure_channel_detected",
|
||||
/** An invalid/incompatible QR code was scanned */
|
||||
InvalidCode = "invalid_code",
|
||||
/** The other device is not signed in */
|
||||
OtherDeviceNotSignedIn = "other_device_not_signed_in",
|
||||
/** The other device is already signed in */
|
||||
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
|
||||
/** Other */
|
||||
Unknown = "unknown",
|
||||
/** The user declined the sign in request */
|
||||
UserDeclined = "user_declined",
|
||||
/** The rendezvous request is missing an ETag header */
|
||||
ETagMissing = "etag_missing",
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
RendezvousChannel,
|
||||
RendezvousTransportDetails,
|
||||
RendezvousTransport,
|
||||
RendezvousFailureReason,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
} from "..";
|
||||
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
|
||||
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
|
||||
@@ -69,6 +69,9 @@ async function importKey(key: Uint8Array): Promise<CryptoKey> {
|
||||
* Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903)
|
||||
* X25519/ECDH key agreement based secure rendezvous channel.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
|
||||
private olmSAS?: SAS;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
Copyright 2024 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 {
|
||||
Curve25519PublicKey,
|
||||
Ecies,
|
||||
EstablishedEcies,
|
||||
QrCodeData,
|
||||
QrCodeMode,
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import {
|
||||
ClientRendezvousFailureReason,
|
||||
MSC4108FailureReason,
|
||||
MSC4108Payload,
|
||||
RendezvousError,
|
||||
RendezvousFailureListener,
|
||||
} from "..";
|
||||
import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
/**
|
||||
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
|
||||
* secure rendezvous session protocol.
|
||||
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle.
|
||||
*/
|
||||
export class MSC4108SecureChannel {
|
||||
private readonly secureChannel: Ecies;
|
||||
private establishedChannel?: EstablishedEcies;
|
||||
private connected = false;
|
||||
|
||||
public constructor(
|
||||
private rendezvousSession: MSC4108RendezvousSession,
|
||||
private theirPublicKey?: Curve25519PublicKey,
|
||||
public onFailure?: RendezvousFailureListener,
|
||||
) {
|
||||
this.secureChannel = new Ecies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code for the current session.
|
||||
* @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`.
|
||||
* @param serverName the name of the homeserver to connect to, as defined by server discovery in the spec, required for `Reciprocate` mode.
|
||||
*/
|
||||
public async generateCode(mode: QrCodeMode.Login): Promise<Uint8Array>;
|
||||
public async generateCode(mode: QrCodeMode.Reciprocate, serverName: string): Promise<Uint8Array>;
|
||||
public async generateCode(mode: QrCodeMode, serverName?: string): Promise<Uint8Array> {
|
||||
const { url } = this.rendezvousSession;
|
||||
|
||||
if (!url) {
|
||||
throw new Error("No rendezvous session URL");
|
||||
}
|
||||
|
||||
return new QrCodeData(
|
||||
this.secureChannel.public_key(),
|
||||
url,
|
||||
mode === QrCodeMode.Reciprocate ? serverName : undefined,
|
||||
).toBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the check code for the secure channel or undefined if not generated yet.
|
||||
*/
|
||||
public getCheckCode(): string | undefined {
|
||||
const x = this.establishedChannel?.check_code();
|
||||
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(x.as_bytes())
|
||||
.map((b) => `${b % 10}`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects and establishes a secure channel with the other device.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
throw new Error("Channel already connected");
|
||||
}
|
||||
|
||||
if (this.theirPublicKey) {
|
||||
// We are the scanning device
|
||||
const result = this.secureChannel.establish_outbound_channel(
|
||||
this.theirPublicKey,
|
||||
"MATRIX_QR_CODE_LOGIN_INITIATE",
|
||||
);
|
||||
this.establishedChannel = result.channel;
|
||||
|
||||
/*
|
||||
Secure Channel step 4. Device S sends the initial message
|
||||
|
||||
Nonce := 0
|
||||
SH := ECDH(Ss, Gp)
|
||||
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
|
||||
TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE")
|
||||
Nonce := Nonce + 2
|
||||
LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp)
|
||||
*/
|
||||
{
|
||||
logger.info("Sending LoginInitiateMessage");
|
||||
await this.rendezvousSession.send(result.initial_message);
|
||||
}
|
||||
|
||||
/*
|
||||
Secure Channel step 6. Verification by Device S
|
||||
|
||||
Nonce_G := 1
|
||||
(TaggedCiphertext, Sp) := Unpack(Message)
|
||||
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext)
|
||||
Nonce_G := Nonce_G + 2
|
||||
|
||||
unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK":
|
||||
FAIL
|
||||
*/
|
||||
{
|
||||
logger.info("Waiting for LoginOkMessage");
|
||||
const ciphertext = await this.rendezvousSession.receive();
|
||||
|
||||
if (!ciphertext) {
|
||||
throw new RendezvousError(
|
||||
"No response from other device",
|
||||
MSC4108FailureReason.UnexpectedMessageReceived,
|
||||
);
|
||||
}
|
||||
const candidateLoginOkMessage = await this.decrypt(ciphertext);
|
||||
|
||||
if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") {
|
||||
throw new RendezvousError(
|
||||
"Invalid response from other device",
|
||||
ClientRendezvousFailureReason.InsecureChannelDetected,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 6 is now complete. We trust the channel
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
Secure Channel step 5. Device G confirms
|
||||
|
||||
Nonce_S := 0
|
||||
(TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage)
|
||||
SH := ECDH(Gs, Sp)
|
||||
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
|
||||
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext)
|
||||
Nonce_S := Nonce_S + 2
|
||||
*/
|
||||
// wait for the other side to send us their public key
|
||||
logger.info("Waiting for LoginInitiateMessage");
|
||||
const loginInitiateMessage = await this.rendezvousSession.receive();
|
||||
if (!loginInitiateMessage) {
|
||||
throw new Error("No response from other device");
|
||||
}
|
||||
|
||||
const { channel, message: candidateLoginInitiateMessage } =
|
||||
this.secureChannel.establish_inbound_channel(loginInitiateMessage);
|
||||
this.establishedChannel = channel;
|
||||
|
||||
if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") {
|
||||
throw new RendezvousError(
|
||||
"Invalid response from other device",
|
||||
ClientRendezvousFailureReason.InsecureChannelDetected,
|
||||
);
|
||||
}
|
||||
logger.info("LoginInitiateMessage received");
|
||||
|
||||
logger.info("Sending LoginOkMessage");
|
||||
const loginOkMessage = await this.encrypt("MATRIX_QR_CODE_LOGIN_OK");
|
||||
await this.rendezvousSession.send(loginOkMessage);
|
||||
|
||||
// Step 5 is complete. We don't yet trust the channel
|
||||
|
||||
// next step will be for the user to confirm the check code on the other device
|
||||
}
|
||||
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
private async decrypt(ciphertext: string): Promise<string> {
|
||||
if (!this.establishedChannel) {
|
||||
throw new Error("Channel closed");
|
||||
}
|
||||
|
||||
return this.establishedChannel.decrypt(ciphertext);
|
||||
}
|
||||
|
||||
private async encrypt(plaintext: string): Promise<string> {
|
||||
if (!this.establishedChannel) {
|
||||
throw new Error("Channel closed");
|
||||
}
|
||||
|
||||
return this.establishedChannel.encrypt(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a payload securely to the other device.
|
||||
* @param payload the payload to encrypt and send
|
||||
*/
|
||||
public async secureSend<T extends MSC4108Payload>(payload: T): Promise<void> {
|
||||
if (!this.connected) {
|
||||
throw new Error("Channel closed");
|
||||
}
|
||||
|
||||
const stringifiedPayload = JSON.stringify(payload);
|
||||
logger.debug(`=> {"type": ${JSON.stringify(payload.type)}, ...}`);
|
||||
|
||||
await this.rendezvousSession.send(await this.encrypt(stringifiedPayload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an encrypted payload from the other device and decrypts it.
|
||||
*/
|
||||
public async secureReceive<T extends MSC4108Payload>(): Promise<Partial<T> | undefined> {
|
||||
if (!this.establishedChannel) {
|
||||
throw new Error("Channel closed");
|
||||
}
|
||||
|
||||
const ciphertext = await this.rendezvousSession.receive();
|
||||
if (!ciphertext) {
|
||||
return undefined;
|
||||
}
|
||||
const plaintext = await this.decrypt(ciphertext);
|
||||
const json = JSON.parse(plaintext);
|
||||
|
||||
logger.debug(`<= {"type": ${JSON.stringify(json.type)}, ...}`);
|
||||
return json as Partial<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the secure channel.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
await this.rendezvousSession.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the secure channel.
|
||||
* @param reason the reason for the cancellation
|
||||
*/
|
||||
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
|
||||
try {
|
||||
await this.rendezvousSession.cancel(reason);
|
||||
this.onFailure?.(reason);
|
||||
} finally {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the rendezvous session has been cancelled.
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this.rendezvousSession.cancelled;
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated in favour of MSC4108-based implementation
|
||||
*/
|
||||
export * from "./MSC3903ECDHv2RendezvousChannel";
|
||||
export * from "./MSC4108SecureChannel";
|
||||
|
||||
@@ -14,10 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated in favour of MSC4108-based implementation
|
||||
*/
|
||||
export * from "./MSC3906Rendezvous";
|
||||
export * from "./MSC4108SignInWithQR";
|
||||
export * from "./RendezvousChannel";
|
||||
export * from "./RendezvousCode";
|
||||
export * from "./RendezvousError";
|
||||
export * from "./RendezvousFailureReason";
|
||||
export * from "./RendezvousIntent";
|
||||
export * from "./RendezvousTransport";
|
||||
export * from "./transports";
|
||||
export * from "./channels";
|
||||
|
||||
@@ -20,7 +20,7 @@ import { logger } from "../../logger";
|
||||
import { sleep } from "../../utils";
|
||||
import {
|
||||
RendezvousFailureListener,
|
||||
RendezvousFailureReason,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
RendezvousTransport,
|
||||
RendezvousTransportDetails,
|
||||
} from "..";
|
||||
@@ -37,6 +37,9 @@ export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousT
|
||||
* Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886)
|
||||
* simple HTTP rendezvous protocol.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3886SimpleHttpRendezvousTransport<T extends {}> implements RendezvousTransport<T> {
|
||||
private uri?: string;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
Copyright 2024 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 { logger } from "../../logger";
|
||||
import { sleep } from "../../utils";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousFailureListener } from "..";
|
||||
import { MatrixClient, Method } from "../../matrix";
|
||||
import { ClientPrefix } from "../../http-api";
|
||||
|
||||
/**
|
||||
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
|
||||
* insecure rendezvous session protocol.
|
||||
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
*/
|
||||
export class MSC4108RendezvousSession {
|
||||
public url?: string;
|
||||
private readonly client?: MatrixClient;
|
||||
private readonly fallbackRzServer?: string;
|
||||
private readonly fetchFn?: typeof global.fetch;
|
||||
private readonly onFailure?: RendezvousFailureListener;
|
||||
private etag?: string;
|
||||
private expiresAt?: Date;
|
||||
private expiresTimer?: ReturnType<typeof setTimeout>;
|
||||
private _cancelled = false;
|
||||
private _ready = false;
|
||||
|
||||
public constructor({
|
||||
onFailure,
|
||||
url,
|
||||
fetchFn,
|
||||
}: {
|
||||
fetchFn?: typeof global.fetch;
|
||||
onFailure?: RendezvousFailureListener;
|
||||
url: string;
|
||||
});
|
||||
public constructor({
|
||||
onFailure,
|
||||
client,
|
||||
fallbackRzServer,
|
||||
fetchFn,
|
||||
}: {
|
||||
fetchFn?: typeof global.fetch;
|
||||
onFailure?: RendezvousFailureListener;
|
||||
client?: MatrixClient;
|
||||
fallbackRzServer?: string;
|
||||
});
|
||||
public constructor({
|
||||
fetchFn,
|
||||
onFailure,
|
||||
url,
|
||||
client,
|
||||
fallbackRzServer,
|
||||
}: {
|
||||
fetchFn?: typeof global.fetch;
|
||||
onFailure?: RendezvousFailureListener;
|
||||
url?: string;
|
||||
client?: MatrixClient;
|
||||
fallbackRzServer?: string;
|
||||
}) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.onFailure = onFailure;
|
||||
this.client = client;
|
||||
this.fallbackRzServer = fallbackRzServer;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the channel is ready to be used.
|
||||
*/
|
||||
public get ready(): boolean {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the channel has been cancelled.
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this._cancelled;
|
||||
}
|
||||
|
||||
private fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
|
||||
if (this.fetchFn) {
|
||||
return this.fetchFn(resource, options);
|
||||
}
|
||||
return global.fetch(resource, options);
|
||||
}
|
||||
|
||||
private async getPostEndpoint(): Promise<string | undefined> {
|
||||
if (this.client) {
|
||||
try {
|
||||
if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) {
|
||||
return this.client.http
|
||||
.getUrl("/org.matrix.msc4108/rendezvous", undefined, ClientPrefix.Unstable)
|
||||
.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to get unstable features", err);
|
||||
}
|
||||
}
|
||||
|
||||
return this.fallbackRzServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends data via the rendezvous channel.
|
||||
* @param data the payload to send
|
||||
*/
|
||||
public async send(data: string): Promise<void> {
|
||||
if (this._cancelled) {
|
||||
return;
|
||||
}
|
||||
const method = this.url ? Method.Put : Method.Post;
|
||||
const uri = this.url ?? (await this.getPostEndpoint());
|
||||
|
||||
if (!uri) {
|
||||
throw new Error("Invalid rendezvous URI");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "text/plain" };
|
||||
|
||||
// if we didn't create the rendezvous channel, we need to fetch the first etag if needed
|
||||
if (!this.etag && this.url) {
|
||||
await this.receive();
|
||||
}
|
||||
|
||||
if (this.etag) {
|
||||
headers["if-match"] = this.etag;
|
||||
}
|
||||
|
||||
logger.info(`=> ${method} ${uri} with ${data} if-match: ${this.etag}`);
|
||||
|
||||
const res = await this.fetch(uri, { method, headers, body: data, redirect: "follow" });
|
||||
if (res.status === 404) {
|
||||
return this.cancel(ClientRendezvousFailureReason.Unknown);
|
||||
}
|
||||
this.etag = res.headers.get("etag") ?? undefined;
|
||||
|
||||
logger.info(`Received etag: ${this.etag}`);
|
||||
|
||||
if (method === Method.Post) {
|
||||
const expires = res.headers.get("expires");
|
||||
if (expires) {
|
||||
if (this.expiresTimer) {
|
||||
clearTimeout(this.expiresTimer);
|
||||
this.expiresTimer = undefined;
|
||||
}
|
||||
this.expiresAt = new Date(expires);
|
||||
this.expiresTimer = setTimeout(() => {
|
||||
this.expiresTimer = undefined;
|
||||
this.cancel(ClientRendezvousFailureReason.Expired);
|
||||
}, this.expiresAt.getTime() - Date.now());
|
||||
}
|
||||
// MSC4108: we expect a JSON response with a rendezvous URL
|
||||
const json = await res.json();
|
||||
if (typeof json.url !== "string") {
|
||||
throw new Error("No rendezvous URL given");
|
||||
}
|
||||
this.url = json.url;
|
||||
this._ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives data from the rendezvous channel.
|
||||
* @return the returned promise won't resolve until new data is acquired or the channel is closed either by the server or the other party.
|
||||
*/
|
||||
public async receive(): Promise<string | undefined> {
|
||||
if (!this.url) {
|
||||
throw new Error("Rendezvous not set up");
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (this._cancelled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.etag) {
|
||||
headers["if-none-match"] = this.etag;
|
||||
}
|
||||
|
||||
logger.info(`=> GET ${this.url} if-none-match: ${this.etag}`);
|
||||
const poll = await this.fetch(this.url, { method: Method.Get, headers });
|
||||
|
||||
if (poll.status === 404) {
|
||||
await this.cancel(ClientRendezvousFailureReason.Unknown);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// rely on server expiring the channel rather than checking ourselves
|
||||
|
||||
const etag = poll.headers.get("etag") ?? undefined;
|
||||
if (poll.headers.get("content-type") !== "text/plain") {
|
||||
this.etag = etag;
|
||||
} else if (poll.status === 200) {
|
||||
if (!etag) {
|
||||
// Some browsers & extensions block the ETag header for anti-tracking purposes
|
||||
// We try and detect this so the client can give the user a somewhat helpful message
|
||||
await this.cancel(ClientRendezvousFailureReason.ETagMissing);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.etag = etag;
|
||||
const text = await poll.text();
|
||||
logger.info(`Received: ${text} with etag ${this.etag}`);
|
||||
return text;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the rendezvous channel.
|
||||
* If the reason is user_declined or user_cancelled then the channel will also be closed.
|
||||
* @param reason the reason to cancel with
|
||||
*/
|
||||
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
|
||||
if (this._cancelled) return;
|
||||
if (this.expiresTimer) {
|
||||
clearTimeout(this.expiresTimer);
|
||||
this.expiresTimer = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
reason === ClientRendezvousFailureReason.Unknown &&
|
||||
this.expiresAt &&
|
||||
this.expiresAt.getTime() < Date.now()
|
||||
) {
|
||||
reason = ClientRendezvousFailureReason.Expired;
|
||||
}
|
||||
|
||||
this._cancelled = true;
|
||||
this._ready = false;
|
||||
this.onFailure?.(reason);
|
||||
|
||||
if (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the rendezvous channel.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.expiresTimer) {
|
||||
clearTimeout(this.expiresTimer);
|
||||
this.expiresTimer = undefined;
|
||||
}
|
||||
|
||||
if (!this.url) return;
|
||||
try {
|
||||
await this.fetch(this.url, { method: Method.Delete });
|
||||
} catch (e) {
|
||||
logger.warn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated in favour of MSC4108-based implementation
|
||||
*/
|
||||
export * from "./MSC3886SimpleHttpRendezvousTransport";
|
||||
export * from "./MSC4108RendezvousSession";
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CrossSigningKey,
|
||||
CrossSigningKeyInfo,
|
||||
CrossSigningStatus,
|
||||
CryptoApi,
|
||||
CryptoCallbacks,
|
||||
Curve25519AuthData,
|
||||
DecryptionFailureCode,
|
||||
@@ -308,6 +309,40 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
|
||||
}
|
||||
|
||||
const authData = <Curve25519AuthData>backupInfo.auth_data;
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
|
||||
|
||||
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
||||
throw new Error(`getBackupDecryptor key mismatch error`);
|
||||
}
|
||||
|
||||
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public async importBackedUpRoomKeys(
|
||||
keys: IMegolmSessionData[],
|
||||
backupVersion: string,
|
||||
opts?: ImportRoomKeysOpts,
|
||||
): Promise<void> {
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// CryptoApi implementation
|
||||
@@ -1065,10 +1100,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}
|
||||
|
||||
try {
|
||||
const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] =
|
||||
await device.requestVerification(
|
||||
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
const [request, outgoingRequest] = device.requestVerification(
|
||||
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
|
||||
);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
|
||||
return new RustVerificationRequest(
|
||||
this.olmMachine,
|
||||
@@ -1169,6 +1203,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Signs the given object with the current device and current identity (if available).
|
||||
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
|
||||
*
|
||||
* Helper for {@link RustCrypto#resetKeyBackup}.
|
||||
*
|
||||
* @param obj - The object to sign
|
||||
*/
|
||||
private async signObject<T extends ISignableObject & object>(obj: T): Promise<void> {
|
||||
@@ -1192,48 +1228,14 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
|
||||
}
|
||||
|
||||
const authData = <Curve25519AuthData>backupInfo.auth_data;
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
|
||||
|
||||
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
||||
throw new Error(`getBackupDecryptor key mismatch error`);
|
||||
}
|
||||
|
||||
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public async importBackedUpRoomKeys(
|
||||
keys: IMegolmSessionData[],
|
||||
backupVersion: string,
|
||||
opts?: ImportRoomKeysOpts,
|
||||
): Promise<void> {
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
|
||||
* Implementation of {@link CryptoApi#isDehydrationSupported}.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return await this.dehydratedDeviceManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#startDehydration}.
|
||||
* Implementation of {@link CryptoApi#startDehydration}.
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
|
||||
@@ -1242,6 +1244,26 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return await this.dehydratedDeviceManager.start(createNewKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#importSecretsBundle}.
|
||||
*/
|
||||
public async importSecretsBundle(
|
||||
secrets: Parameters<NonNullable<CryptoApi["importSecretsBundle"]>>[0],
|
||||
): Promise<void> {
|
||||
const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets);
|
||||
await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#exportSecretsBundle}.
|
||||
*/
|
||||
public async exportSecretsBundle(): ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>> {
|
||||
const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle();
|
||||
const secrets = secretsBundle.to_json();
|
||||
secretsBundle.free();
|
||||
return secrets;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
|
||||
@@ -72,30 +72,40 @@ export class RustVerificationRequest
|
||||
private readonly supportedVerificationMethods: string[],
|
||||
) {
|
||||
super();
|
||||
|
||||
this.reEmitter = new TypedReEmitter(this);
|
||||
|
||||
const onChange = async (): Promise<void> => {
|
||||
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
|
||||
// Obviously, the Rust object maintains a reference to the callback function. If the callback function maintains
|
||||
// a reference to the Rust object, then we have a reference cycle which means that `RustVerificationRequest`
|
||||
// will never be garbage-collected, and hence the underlying rust object will never be freed.
|
||||
//
|
||||
// To avoid this reference cycle, use a weak reference in the callback function. If the `RustVerificationRequest`
|
||||
// gets garbage-collected, then there is nothing to update!
|
||||
const weakThis = new WeakRef(this);
|
||||
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
|
||||
}
|
||||
|
||||
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
|
||||
// - we now have a `Verification` where we lacked one before
|
||||
// - we have transitioned from QR to SAS
|
||||
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
|
||||
// tried to start verification at the same time, and we lost the tie breaking
|
||||
if (verification instanceof RustSdkCryptoJs.Sas) {
|
||||
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
|
||||
this.setVerifier(new RustSASVerifier(verification, this, outgoingRequestProcessor));
|
||||
} else if (this._verifier instanceof RustSASVerifier) {
|
||||
this._verifier.replaceInner(verification);
|
||||
}
|
||||
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
|
||||
this.setVerifier(new RustQrCodeVerifier(verification, outgoingRequestProcessor));
|
||||
/**
|
||||
* Hook which is called when the underlying rust class notifies us that there has been a change.
|
||||
*/
|
||||
private onChange(): void {
|
||||
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
|
||||
|
||||
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
|
||||
// - we now have a `Verification` where we lacked one before
|
||||
// - we have transitioned from QR to SAS
|
||||
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
|
||||
// tried to start verification at the same time, and we lost the tie breaking
|
||||
if (verification instanceof RustSdkCryptoJs.Sas) {
|
||||
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
|
||||
this.setVerifier(new RustSASVerifier(verification, this, this.outgoingRequestProcessor));
|
||||
} else if (this._verifier instanceof RustSASVerifier) {
|
||||
this._verifier.replaceInner(verification);
|
||||
}
|
||||
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
|
||||
this.setVerifier(new RustQrCodeVerifier(verification, this.outgoingRequestProcessor));
|
||||
}
|
||||
|
||||
this.emit(VerificationRequestEvent.Change);
|
||||
};
|
||||
inner.registerChangesCallback(onChange);
|
||||
this.emit(VerificationRequestEvent.Change);
|
||||
}
|
||||
|
||||
private setVerifier(verifier: RustSASVerifier | RustQrCodeVerifier): void {
|
||||
@@ -473,9 +483,12 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
|
||||
super();
|
||||
|
||||
this.completionDeferred = defer();
|
||||
inner.registerChangesCallback(async () => {
|
||||
this.onChange();
|
||||
});
|
||||
|
||||
// As with RustVerificationRequest, we need to avoid a reference cycle.
|
||||
// See the comments in RustVerificationRequest.
|
||||
const weakThis = new WeakRef(this);
|
||||
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
|
||||
|
||||
// stop the runtime complaining if nobody catches a failure
|
||||
this.completionDeferred.promise.catch(() => null);
|
||||
}
|
||||
@@ -752,9 +765,12 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
|
||||
public replaceInner(inner: RustSdkCryptoJs.Sas): void {
|
||||
if (this.inner != inner) {
|
||||
this.inner = inner;
|
||||
inner.registerChangesCallback(async () => {
|
||||
this.onChange();
|
||||
});
|
||||
|
||||
// As with RustVerificationRequest, we need to avoid a reference cycle.
|
||||
// See the comments in RustVerificationRequest.
|
||||
const weakThis = new WeakRef(this);
|
||||
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
|
||||
|
||||
// replaceInner will only get called if we started the verification at the same time as the other side, and we lost
|
||||
// the tie breaker. So we need to re-accept their verification.
|
||||
this.sendAccept();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright 2024 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 { IHttpOpts, MatrixHttpApi, Method } from "./http-api";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// How often we update the server capabilities.
|
||||
// 6 hours - an arbitrary value, but they should change very infrequently.
|
||||
const CAPABILITIES_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
// How long we want before retrying if we couldn't fetch
|
||||
const CAPABILITIES_RETRY_MS = 30 * 1000;
|
||||
|
||||
export interface ICapability {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IChangePasswordCapability extends ICapability {}
|
||||
|
||||
export interface IThreadsCapability extends ICapability {}
|
||||
|
||||
export interface IGetLoginTokenCapability extends ICapability {}
|
||||
|
||||
export interface ISetDisplayNameCapability extends ICapability {}
|
||||
|
||||
export interface ISetAvatarUrlCapability extends ICapability {}
|
||||
|
||||
export enum RoomVersionStability {
|
||||
Stable = "stable",
|
||||
Unstable = "unstable",
|
||||
}
|
||||
|
||||
export interface IRoomVersionsCapability {
|
||||
default: string;
|
||||
available: Record<string, RoomVersionStability>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the capabilities advertised by a homeserver as defined by
|
||||
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
|
||||
*/
|
||||
export interface Capabilities {
|
||||
[key: string]: any;
|
||||
"m.change_password"?: IChangePasswordCapability;
|
||||
"m.room_versions"?: IRoomVersionsCapability;
|
||||
"io.element.thread"?: IThreadsCapability;
|
||||
"m.get_login_token"?: IGetLoginTokenCapability;
|
||||
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
|
||||
"m.set_displayname"?: ISetDisplayNameCapability;
|
||||
"m.set_avatar_url"?: ISetAvatarUrlCapability;
|
||||
}
|
||||
|
||||
type CapabilitiesResponse = {
|
||||
capabilities: Capabilities;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages storing and periodically refreshing the server capabilities.
|
||||
*/
|
||||
export class ServerCapabilities {
|
||||
private capabilities?: Capabilities;
|
||||
private retryTimeout?: ReturnType<typeof setTimeout>;
|
||||
private refreshTimeout?: ReturnType<typeof setInterval>;
|
||||
|
||||
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
|
||||
|
||||
/**
|
||||
* Starts periodically fetching the server capabilities.
|
||||
*/
|
||||
public start(): void {
|
||||
this.poll().then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service
|
||||
*/
|
||||
public stop(): void {
|
||||
this.clearTimeouts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached capabilities, or undefined if none are cached.
|
||||
* @returns the current capabilities, if any.
|
||||
*/
|
||||
public getCachedCapabilities(): Capabilities | undefined {
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest server capabilities from the homeserver and returns them, or rejects
|
||||
* on failure.
|
||||
*/
|
||||
public fetchCapabilities = async (): Promise<Capabilities> => {
|
||||
const resp = await this.http.authedRequest<CapabilitiesResponse>(Method.Get, "/capabilities");
|
||||
this.capabilities = resp["capabilities"];
|
||||
return this.capabilities;
|
||||
};
|
||||
|
||||
private poll = async (): Promise<void> => {
|
||||
try {
|
||||
await this.fetchCapabilities();
|
||||
this.clearTimeouts();
|
||||
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
|
||||
logger.debug("Fetched new server capabilities");
|
||||
} catch (e) {
|
||||
this.clearTimeouts();
|
||||
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
|
||||
this.retryTimeout = setTimeout(this.poll, howLong);
|
||||
logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
|
||||
}
|
||||
};
|
||||
|
||||
private clearTimeouts(): void {
|
||||
if (this.refreshTimeout) {
|
||||
clearInterval(this.refreshTimeout);
|
||||
this.refreshTimeout = undefined;
|
||||
}
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout);
|
||||
this.retryTimeout = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -407,9 +407,12 @@ export async function logDuration<T>(logger: BaseLogger, name: string, block: ()
|
||||
|
||||
/**
|
||||
* Promise/async version of {@link setImmediate}.
|
||||
*
|
||||
* Implementation is based on `setTimeout` for wider compatibility.
|
||||
* @deprecated Use {@link sleep} instead.
|
||||
*/
|
||||
export function immediate(): Promise<void> {
|
||||
return new Promise(setImmediate);
|
||||
return new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
|
||||
export function isNullOrUndefined(val: any): boolean {
|
||||
|
||||
+30
-12
@@ -2381,22 +2381,40 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
// RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF before v113
|
||||
if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return;
|
||||
|
||||
const recvCodecs = RTCRtpReceiver.getCapabilities("video")!.codecs;
|
||||
const sendCodecs = RTCRtpSender.getCapabilities("video")!.codecs;
|
||||
const codecs = [...sendCodecs, ...recvCodecs];
|
||||
|
||||
for (const codec of codecs) {
|
||||
if (codec.mimeType === "video/rtx") {
|
||||
const rtxCodecIndex = codecs.indexOf(codec);
|
||||
codecs.splice(rtxCodecIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const screenshareVideoTransceiver = this.transceivers.get(
|
||||
getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "video"),
|
||||
);
|
||||
|
||||
// setCodecPreferences isn't supported on FF (as of v113)
|
||||
screenshareVideoTransceiver?.setCodecPreferences?.(codecs);
|
||||
if (!screenshareVideoTransceiver || !screenshareVideoTransceiver.setCodecPreferences) return;
|
||||
|
||||
const recvCodecs = RTCRtpReceiver.getCapabilities("video")!.codecs;
|
||||
const sendCodecs = RTCRtpSender.getCapabilities("video")!.codecs;
|
||||
const codecs = [];
|
||||
|
||||
for (const codec of [...recvCodecs, ...sendCodecs]) {
|
||||
if (codec.mimeType !== "video/rtx") {
|
||||
codecs.push(codec);
|
||||
try {
|
||||
screenshareVideoTransceiver.setCodecPreferences(codecs);
|
||||
} catch (e) {
|
||||
// Specifically, Chrome around version 125 and Electron 30 (which is Chromium 124) return an H.264 codec in
|
||||
// the sender's capabilities but throw when you try to set it. Hence... this mess.
|
||||
// Specifically, that codec is:
|
||||
// {
|
||||
// clockRate: 90000,
|
||||
// mimeType: "video/H264",
|
||||
// sdpFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034",
|
||||
// }
|
||||
logger.info(
|
||||
"Working around buggy WebRTC impl: claimed to support codec but threw when setting codec preferences",
|
||||
codec,
|
||||
e,
|
||||
);
|
||||
codecs.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onNegotiationNeeded = async (): Promise<void> => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"target": "es2022",
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
|
||||
Reference in New Issue
Block a user