Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 946cf4f359 | |||
| b7cce93edc | |||
| e5c8c20a34 | |||
| 137379b7b7 | |||
| 4981efa54b | |||
| 6e9740d787 | |||
| 70257e0ab4 | |||
| 9baba151c6 | |||
| b4672e26ec | |||
| 940d358b0e | |||
| 161c12f5d5 | |||
| de659d6431 | |||
| b095aa600d | |||
| 18ea8befdc | |||
| 4f9ca2c697 | |||
| 57a4dc8841 | |||
| 841c02e56d | |||
| 6ece4c3c16 | |||
| 5a3c07f91d | |||
| 3ed4b3ed50 | |||
| 9ca9bd9baf | |||
| 0265f6ea2d | |||
| 8f597f0f87 | |||
| 67df2a53c4 | |||
| 8367277894 | |||
| 4efb27354f | |||
| 28bc90563e | |||
| f9be1bf57a | |||
| adaf921623 | |||
| cdece6cb9f | |||
| d438e25f87 | |||
| 73d8f4384d | |||
| b0cb6aa724 | |||
| dfc26f8aa1 | |||
| 286fb8f752 | |||
| 921de6807d | |||
| d4e7b949e7 | |||
| 1e80538cfb | |||
| 73cbcfa4ee | |||
| 99972ce0a9 | |||
| 44399f6017 | |||
| c387f30e5c | |||
| 12a9875c46 | |||
| 74f5efc4ef | |||
| 43d47982ed | |||
| 94fb489952 | |||
| 75ae05e5eb | |||
| 9058b79c39 | |||
| 671dd2ca40 | |||
| 34f35393ff | |||
| e206a12902 |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5
|
||||
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
+1
-2
@@ -13,8 +13,7 @@ out
|
||||
/dist
|
||||
/lib
|
||||
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
# tarball created by `npm pack` / `yarn pack`
|
||||
/matrix-js-sdk-*.tgz
|
||||
|
||||
.vscode
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
Changes in [37.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.11.0) (2025-07-15)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update README to make Element sponsorship explicit ([#4901](https://github.com/matrix-org/matrix-js-sdk/pull/4901)). Contributed by @neilisfragile.
|
||||
* Use client logger in more places (crypto code) ([#4900](https://github.com/matrix-org/matrix-js-sdk/pull/4900)). Contributed by @richvdh.
|
||||
* Use client logger in `MatrixRTCSessionManager` ([#4898](https://github.com/matrix-org/matrix-js-sdk/pull/4898)). Contributed by @richvdh.
|
||||
* Use client logger in more places (core code) ([#4899](https://github.com/matrix-org/matrix-js-sdk/pull/4899)). Contributed by @richvdh.
|
||||
* crypto: Add new `ClientEvent.ReceivedToDeviceMessage` with proper `OlmEncryptionInfo` support ([#4891](https://github.com/matrix-org/matrix-js-sdk/pull/4891)). Contributed by @BillCarsonFr.
|
||||
|
||||
|
||||
Changes in [37.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.10.0) (2025-07-01)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update matrix-sdk-crypto-wasm to `15.0.0` ([#4882](https://github.com/matrix-org/matrix-js-sdk/pull/4882)). Contributed by @richvdh.
|
||||
* Allow customizing the IndexedDB database prefix used by Rust crypto. ([#4878](https://github.com/matrix-org/matrix-js-sdk/pull/4878)). Contributed by @clokep.
|
||||
* Remove `@matrix-org/olm` from dependency list ([#4876](https://github.com/matrix-org/matrix-js-sdk/pull/4876)). Contributed by @richvdh.
|
||||
* Redact on ban: Client implementation ([#4867](https://github.com/matrix-org/matrix-js-sdk/pull/4867)). Contributed by @turt2live.
|
||||
|
||||
|
||||
Changes in [37.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.9.0) (2025-06-17)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Ensure we send spec-compliant filter strings by stripping out null values ([#4865](https://github.com/matrix-org/matrix-js-sdk/pull/4865)). Contributed by @t3chguy.
|
||||
* Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) ([#4861](https://github.com/matrix-org/matrix-js-sdk/pull/4861)). Contributed by @toger5.
|
||||
* Include extraParams in all HTTP requests ([#4860](https://github.com/matrix-org/matrix-js-sdk/pull/4860)). Contributed by @rsb-tbg.
|
||||
|
||||
|
||||
Changes in [37.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.8.0) (2025-06-10)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
|
||||
browser or in Node.js.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source srcset="contrib/element-logo-light.png" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="contrib/element-logo-dark.png" media="(prefers-color-scheme: light)">
|
||||
<img src="contrib/element-logo-fallback.png" alt="Element logo">
|
||||
</picture>
|
||||
|
||||
<br>
|
||||
|
||||
Development and maintenance is proudly sponsored by [Element](https://element.io). Element uses the SDK in their flagship [web](https://github.com/element-hq/element-web) and [desktop](https://github.com/element-hq/element-desktop) clients.
|
||||
|
||||
The SDK is also the basis for multiple Matrix projects and we welcome contributions from all.
|
||||
|
||||
---
|
||||
|
||||
#### Minimum Matrix server version: v1.1
|
||||
|
||||
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+5
-6
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.8.0",
|
||||
"version": "37.11.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn build",
|
||||
"prepare": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"clean": "rimraf lib",
|
||||
"build": "yarn build:dev",
|
||||
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
|
||||
"build": "yarn clean && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"gendoc": "typedoc",
|
||||
@@ -50,8 +49,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^14.2.0",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.0.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -81,6 +79,7 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
|
||||
@@ -19,9 +19,6 @@ limitations under the License.
|
||||
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
|
||||
/* eslint-disable jest/no-standalone-expect */
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "./olm-loader";
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
|
||||
|
||||
@@ -19,9 +19,9 @@ import anotherjson from "another-json";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import type FetchMock from "fetch-mock";
|
||||
import type Olm from "@matrix-org/olm";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import {
|
||||
emitPromise,
|
||||
@@ -124,7 +124,6 @@ async function expectSendRoomKey(
|
||||
recipientOlmAccount: Olm.Account,
|
||||
recipientOlmSession: Olm.Session | null = null,
|
||||
): Promise<Olm.InboundGroupSession> {
|
||||
const Olm = globalThis.Olm;
|
||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||
|
||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||
@@ -207,14 +206,6 @@ async function expectSendMegolmMessage(
|
||||
}
|
||||
|
||||
describe("crypto", () => {
|
||||
if (!globalThis.Olm) {
|
||||
// currently we use libolm to implement the crypto in the tests, so need it to be present.
|
||||
logger.warn("not running megolm tests: Olm not present");
|
||||
return;
|
||||
}
|
||||
|
||||
const Olm = globalThis.Olm;
|
||||
|
||||
let testOlmAccount = {} as unknown as Olm.Account;
|
||||
let testSenderKey = "";
|
||||
|
||||
|
||||
@@ -93,15 +93,15 @@ export function bootstrapCrossSigningTestOlmAccount(
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceMSK = new Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceUSK = new Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceSSK = new Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function createOlmSession(
|
||||
const otkId = Object.keys(keys)[0];
|
||||
const otk = keys[otkId];
|
||||
|
||||
const session = new globalThis.Olm.Session();
|
||||
const session = new Olm.Session();
|
||||
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,23 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the indexed db with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
|
||||
// should have an indexed db now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["my-prefix::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the meta db if given a storageKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
@@ -475,4 +492,22 @@ describe("MatrixClient.clearStores", () => {
|
||||
await matrixClient.clearStores();
|
||||
// No error thrown in clearStores
|
||||
});
|
||||
|
||||
it("should clear the indexeddbs with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(1);
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,22 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { createClient, type MatrixClient } from "../../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type IToDeviceEvent,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "../../../src";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -43,6 +52,8 @@ describe("to-device-messages", () => {
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
beforeEach(
|
||||
async () => {
|
||||
@@ -59,8 +70,8 @@ describe("to-device-messages", () => {
|
||||
});
|
||||
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
const syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
|
||||
// add bob as known user
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
|
||||
@@ -149,4 +160,111 @@ describe("to-device-messages", () => {
|
||||
// for future: check that bob's device can decrypt the ciphertext?
|
||||
});
|
||||
});
|
||||
|
||||
describe("receive to-device-messages", () => {
|
||||
it("Should receive decrypted to-device message via ClientEvent", async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
|
||||
e2eKeyResponder.addDeviceKeys(testDeviceKeys);
|
||||
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
const toDeviceEvent = encryptOlmEvent({
|
||||
sender: "@bob:xyz",
|
||||
senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`],
|
||||
senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`],
|
||||
p2pSession: p2pSession,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
plaincontent: {
|
||||
body: "foo",
|
||||
},
|
||||
plaintype: "m.test.type",
|
||||
});
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
expect(toDeviceEvent.type).toBe("m.room.encrypted");
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
expect(encryptionInfo).not.toBeNull();
|
||||
expect(encryptionInfo!.senderVerified).toBe(false);
|
||||
expect(encryptionInfo!.sender).toBe("@bob:xyz");
|
||||
expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID");
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(true);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
|
||||
it("Should receive clear to-device message via ClientEvent", async () => {
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const toDeviceEvent: IToDeviceEvent = {
|
||||
sender: "@bob:xyz",
|
||||
type: "m.test.type",
|
||||
content: {
|
||||
body: "foo",
|
||||
},
|
||||
};
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
// When the message is not encrypted, we don't have the encryptionInfo.
|
||||
expect(encryptionInfo).toBeNull();
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(false);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await globalThis.Olm.init();
|
||||
await Olm.init();
|
||||
});
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
@@ -110,7 +110,6 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
// we test with both crypto stacks...
|
||||
describe("verification", () => {
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
@@ -254,7 +253,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
@@ -348,7 +347,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
|
||||
|
||||
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
|
||||
|
||||
@@ -205,4 +205,109 @@ describe("MatrixClient opts", function () {
|
||||
expect(res.event_id).toEqual("foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with opts.queryParams", function () {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
|
||||
beforeEach(function () {
|
||||
httpBackend = new HttpBackend();
|
||||
client = new MatrixClient({
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
store: new MemoryStore() as IStore,
|
||||
baseUrl: baseUrl,
|
||||
userId: userId,
|
||||
accessToken: accessToken,
|
||||
queryParams: { user_id: userId },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
it("should include queryParams in matrix server requests", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
|
||||
it("should include queryParams in sync requests", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/versions")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/pushrules")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("POST", "/filter")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, { filter_id: "foo" });
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, syncData);
|
||||
|
||||
client.startClient();
|
||||
await httpBackend.flush("/versions", 1);
|
||||
await httpBackend.flush("/pushrules", 1);
|
||||
await httpBackend.flush("/filter", 1);
|
||||
await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
|
||||
});
|
||||
|
||||
it("should merge queryParams with request-specific params", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
// Should contain both global queryParams and request-specific params
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("SlidingSyncSdk", () => {
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
|
||||
testOpts = testOpts || {};
|
||||
const syncOpts: SyncApiOptions = {};
|
||||
const syncOpts: SyncApiOptions = { logger };
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
httpBackend = testClient.httpBackend;
|
||||
client = testClient.client;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector creations Ltd
|
||||
Copyright 2019 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 "../src/logger";
|
||||
|
||||
// try to load the olm library.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
globalThis.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
@@ -19,3 +19,8 @@ jest.mock("../src/http-api/utils", () => ({
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
}));
|
||||
|
||||
// Dont make test fail too soon due to timeouts while debugging.
|
||||
if (process.env.VSCODE_INSPECTOR_OPTIONS) {
|
||||
jest.setTimeout(60 * 1000 * 5); // 5 minutes
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// load olm before the sdk if possible
|
||||
import "../olm-loader";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventEmitter from "events";
|
||||
import { logger } from "../../src/logger";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
@@ -55,7 +56,7 @@ describe("onResumedSync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
queue = new ToDeviceMessageQueue(mockClient);
|
||||
queue = new ToDeviceMessageQueue(mockClient, logger);
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", async () => {
|
||||
|
||||
@@ -170,4 +170,11 @@ describe("Filter Component", function () {
|
||||
expect(filter.check(noMatchEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("should omit empty values", () => {
|
||||
const filter = new FilterComponent({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
expect(filter.toJSON()).toEqual({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,6 +521,83 @@ describe("FetchHttpApi", () => {
|
||||
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
|
||||
describe("extraParams handling", () => {
|
||||
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, extraParams });
|
||||
};
|
||||
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
const encodedUserId = encodeURIComponent(userId);
|
||||
|
||||
it("should include extraParams in URL when no queryParams provided", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
|
||||
it("should merge extraParams with queryParams", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123", filter: "active" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.get("filter")!).toBe("active");
|
||||
});
|
||||
|
||||
it("should allow queryParams to override extraParams", () => {
|
||||
const extraParams = { user_id: "@default:localhost", version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { user_id: "@override:localhost", userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")).toBe("@override:localhost");
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
});
|
||||
|
||||
it("should handle empty extraParams", () => {
|
||||
const extraParams = {};
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.has("user_id")).toBe(false);
|
||||
});
|
||||
|
||||
it("should work when extraParams is undefined", () => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn });
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?userId=123`);
|
||||
});
|
||||
|
||||
it("should work when queryParams is undefined", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
|
||||
@@ -16,11 +16,12 @@ limitations under the License.
|
||||
|
||||
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -201,58 +202,6 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCallMembershipEvent", () => {
|
||||
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
|
||||
const joinSessionConfig = {};
|
||||
|
||||
const sessionMembershipData: SessionMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA_session",
|
||||
focus_active: mockFocus,
|
||||
foci_preferred: [mockFocus],
|
||||
};
|
||||
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
let sentDelayedState: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
sentDelayedState = new Promise((resolve) => {
|
||||
sendDelayedStateMock = jest.fn(() => {
|
||||
resolve();
|
||||
return {
|
||||
delay_id: "id",
|
||||
};
|
||||
});
|
||||
});
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
});
|
||||
|
||||
async function testSession(membershipData: SessionMembershipData): Promise<void> {
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]);
|
||||
|
||||
expect(sendStateEventMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]);
|
||||
expect(sendDelayedStateMock).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("sends events", async () => {
|
||||
await testSession(sessionMembershipData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
jest.useFakeTimers();
|
||||
@@ -320,28 +269,10 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
|
||||
let sentStateEvent: Promise<void>;
|
||||
let sentDelayedState: Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentStateEvent = new Promise((resolve) => {
|
||||
sendStateEventMock = jest.fn(resolve);
|
||||
});
|
||||
sentDelayedState = new Promise((resolve) => {
|
||||
sendDelayedStateMock = jest.fn(() => {
|
||||
resolve();
|
||||
return {
|
||||
delay_id: "id",
|
||||
};
|
||||
});
|
||||
});
|
||||
sendEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
client._unstable_updateDelayedEvent = jest.fn();
|
||||
@@ -367,67 +298,6 @@ describe("MatrixRTCSession", () => {
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", async () => {
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: DEFAULT_EXPIRE_DURATION,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
// Because we actually want to send the state
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
// For checking if the delayed event is still there or got removed while sending the state.
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||
// For scheduling the delayed event
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
// This returns no error so we do not check if we reschedule the event again. this is done in another test.
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from join config", async () => {
|
||||
const realSetTimeout = setTimeout;
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { membershipEventExpiryMs: 60000 });
|
||||
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60000,
|
||||
foci_preferred: [mockFocus],
|
||||
focus_active: {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onMembershipsChanged", () => {
|
||||
@@ -489,9 +359,9 @@ describe("MatrixRTCSession", () => {
|
||||
let sendToDeviceMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendDelayedStateMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
|
||||
sendToDeviceMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
@@ -569,24 +439,22 @@ describe("MatrixRTCSession", () => {
|
||||
let firstEventSent = false;
|
||||
|
||||
try {
|
||||
const eventSentPromise = new Promise<void>((resolve) => {
|
||||
const eventSentPromise = new Promise<{ event_id: string }>((resolve) => {
|
||||
sendEventMock.mockImplementation(() => {
|
||||
if (!firstEventSent) {
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
firstEventSent = true;
|
||||
const e = new Error() as MatrixError;
|
||||
e.data = {};
|
||||
throw e;
|
||||
} else {
|
||||
resolve();
|
||||
resolve({ event_id: "id" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
// wait for the encryption event to get sent
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
await eventSentPromise;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(2);
|
||||
@@ -878,11 +746,27 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sendKeySpy).toHaveBeenCalledTimes(1);
|
||||
// check that we send the key with index 1 even though the send gets delayed when leaving.
|
||||
// this makes sure we do not use an index that is one too old.
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
sess.memberships.map((m) => ({
|
||||
userId: m.sender,
|
||||
deviceId: m.deviceId,
|
||||
membershipTs: m.createdTs(),
|
||||
})),
|
||||
);
|
||||
// fake a condition in which we send another encryption key event.
|
||||
// this could happen do to someone joining the call.
|
||||
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
|
||||
expect(sendKeySpy).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
sess.memberships.map((m) => ({
|
||||
userId: m.sender,
|
||||
deviceId: m.deviceId,
|
||||
membershipTs: m.createdTs(),
|
||||
})),
|
||||
);
|
||||
jest.advanceTimersByTime(7000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
@@ -993,13 +877,16 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useNewMembershipManager: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
expect(sendToDeviceMock).toHaveBeenCalled();
|
||||
|
||||
// Access private to test
|
||||
expect(sess["encryptionManager"]).toBeInstanceOf(RTCEncryptionManager);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
*/
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -27,43 +24,47 @@ import {
|
||||
type LivekitFocusActive,
|
||||
type SessionMembershipData,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
* @param method The method to wait for.
|
||||
* @param returnVal Provide an optional value that the mocked method should return. (use Promise.resolve(val) or Promise.reject(err))
|
||||
* @returns The promise that resolves once the method is called.
|
||||
*/
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** See waitForMockCall */
|
||||
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function createAsyncHandle(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<void>();
|
||||
/**
|
||||
* A handle to control when in the test flow the provided method resolves (or gets rejected).
|
||||
* @param method The method to control the resolve timing.
|
||||
* @returns
|
||||
*/
|
||||
function createAsyncHandle<T>(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<T>();
|
||||
method.mockImplementation(() => promise);
|
||||
return { reject, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||
*/
|
||||
|
||||
describe.each([
|
||||
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
])("$description", ({ TestMembershipManager }) => {
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
const focusActive: LivekitFocusActive = {
|
||||
@@ -94,12 +95,12 @@ describe.each([
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -110,13 +111,13 @@ describe.each([
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
memberManager.join([focus], focusActive);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
@@ -143,7 +144,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
@@ -212,7 +213,7 @@ describe.each([
|
||||
return Promise.reject(error);
|
||||
});
|
||||
});
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{
|
||||
delayedLeaveEventDelayMs: 9000,
|
||||
},
|
||||
@@ -275,7 +276,7 @@ describe.each([
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -287,14 +288,14 @@ describe.each([
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventDelayMs: 123456 },
|
||||
room,
|
||||
client,
|
||||
@@ -311,13 +312,46 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from config", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipEventExpiryMs: 1234567 },
|
||||
it("rejoins if delayed event is not found (404)", async () => {
|
||||
const RESTART_DELAY = 15000;
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
// Now that we are connected, we set up the mocks.
|
||||
// We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave:
|
||||
// - We wait until the delayed event gets sent and then mock its response to be "not found."
|
||||
// - We enforce a race condition between the sync that informs us that our call membership state event was set to "left"
|
||||
// and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent.
|
||||
// - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left."
|
||||
// (onRTCSessionMemberUpdate)
|
||||
// - Only then do we resolve the sending of the delayed event.
|
||||
// - We test that the manager acknowledges the leave and sends a new membership state event.
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await jest.advanceTimersByTimeAsync(RESTART_DELAY);
|
||||
// first simulate the sync, then resolve sending the delayed event.
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
// Let the scheduler run one iteration so that the new join gets sent
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from config", async () => {
|
||||
const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
@@ -341,7 +375,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -353,7 +387,7 @@ describe.each([
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
@@ -361,7 +395,7 @@ describe.each([
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
@@ -375,9 +409,8 @@ describe.each([
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
);
|
||||
});
|
||||
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||
it("does nothing if not joined !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -387,7 +420,7 @@ describe.each([
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -422,7 +455,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
@@ -430,7 +463,7 @@ describe.each([
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -438,7 +471,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
@@ -459,7 +492,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -480,7 +513,7 @@ describe.each([
|
||||
// TODO: Not sure about this name
|
||||
describe("background timers", () => {
|
||||
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||
room,
|
||||
client,
|
||||
@@ -506,12 +539,12 @@ describe.each([
|
||||
}
|
||||
});
|
||||
|
||||
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||
// because the expires logic was removed for the legacy call manager.
|
||||
// Delayed events should replace it entirely but before they have wide adoption
|
||||
// the expiration logic still makes sense.
|
||||
// TODO: Add git commit when we removed it.
|
||||
async function testExpires(expire: number, headroom?: number) {
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||
room,
|
||||
client,
|
||||
@@ -529,23 +562,23 @@ describe.each([
|
||||
expect(sentMembership.expires).toBe(expire * i);
|
||||
}
|
||||
}
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
it("extends `expires` when call still active", async () => {
|
||||
await testExpires(10_000);
|
||||
});
|
||||
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
|
||||
it("extends `expires` using headroom configuration", async () => {
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected' !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -558,8 +591,8 @@ describe.each([
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -575,7 +608,7 @@ describe.each([
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -592,9 +625,8 @@ describe.each([
|
||||
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
|
||||
it("abandons retry loop and sends new own membership if not present anymore", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
@@ -603,7 +635,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -620,11 +652,10 @@ describe.each([
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -649,7 +680,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("retries sending update delayed leave event restart", () => {
|
||||
it("resends the initial check delayed update event !FailsForLegacy", async () => {
|
||||
it("resends the initial check delayed update event", async () => {
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
@@ -659,7 +690,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
@@ -680,8 +711,8 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("unrecoverable errors", () => {
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation", async () => {
|
||||
const delayEventSendError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -692,7 +723,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -700,8 +731,8 @@ describe.each([
|
||||
}
|
||||
expect(delayEventSendError).toHaveBeenCalled();
|
||||
});
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -712,7 +743,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -720,19 +751,19 @@ describe.each([
|
||||
}
|
||||
expect(delayEventRestartError).toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("retries before failing in case its a network error !FailsForLegacy", async () => {
|
||||
it("retries before failing in case its a network error", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
@@ -749,12 +780,12 @@ describe.each([
|
||||
);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -777,5 +808,5 @@ it("Should prefix log with MembershipManager used", () => {
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const logline: string = spy.mock.calls[0][0];
|
||||
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
|
||||
expect(logline.startsWith("[MembershipManager]")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2025 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 { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
|
||||
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
|
||||
|
||||
describe("OutdatedKeyFilter Test", () => {
|
||||
it("Should buffer and disambiguate keys by timestamp", () => {
|
||||
const filter = new OutdatedKeyFilter();
|
||||
|
||||
const aKey = fakeInboundSessionWithTimestamp(1000);
|
||||
const olderKey = fakeInboundSessionWithTimestamp(300);
|
||||
// Simulate receiving out of order keys
|
||||
|
||||
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
|
||||
// Then we receive the most recent key out of order
|
||||
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
|
||||
// this key is older and should be ignored even if received after
|
||||
expect(isOutdated).toBe(true);
|
||||
});
|
||||
|
||||
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
|
||||
return {
|
||||
keyIndex: 0,
|
||||
creationTS: ts,
|
||||
participantId: "@alice:localhost|ABCDE",
|
||||
key: new Uint8Array(16),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
/*
|
||||
Copyright 2025 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 Mocked } from "jest-mock";
|
||||
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
|
||||
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
let encryptionManager: RTCEncryptionManager;
|
||||
let getMembershipMock: jest.Mock;
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
getMembershipMock = jest.fn().mockReturnValue([]);
|
||||
onEncryptionKeysChanged = jest.fn();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
|
||||
it("should start and stop the transport properly", () => {
|
||||
encryptionManager.join(undefined);
|
||||
|
||||
expect(mockTransport.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
encryptionManager.leave();
|
||||
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
});
|
||||
|
||||
describe("Sharing Keys", () => {
|
||||
it("Set up my key asap even if no key distribution is needed", () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
// After join it is too early, key might be lost as no one is listening yet
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should distribute keys to members on join", async () => {
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
|
||||
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
|
||||
mockTransport.sendKey.mockClear();
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// There are no membership change but the callMembership ts has changed (reset?)
|
||||
// Resend the key
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// Re send the same key to that user
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 2000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should not rotate key when a user join", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should not have incremented the key index
|
||||
0,
|
||||
// And send it to the newly joined only
|
||||
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not resend keys when no changes", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should rotate key when a user leaves and delay the rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
// initial rollout
|
||||
expect(mockTransport.sendKey).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate(updatedMembers);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
// The is rotated but not rolled out yet to give time for the key to be sent
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should have incremented the key index
|
||||
1,
|
||||
// And send it to the updated members
|
||||
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// now should be rolled out
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Receiving Keys", () => {
|
||||
beforeEach(() => {
|
||||
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept keys from transport", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
"BBBBBBBBBBB",
|
||||
4 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"CCCCCCCCCC",
|
||||
8 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("AAAAAAAAAAA"),
|
||||
0,
|
||||
"@bob:example.org:BOBDEVICE",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
4,
|
||||
"@bob:example.org:BOBDEVICE2",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("CCCCCCCCCC"),
|
||||
8,
|
||||
"@carl:example.org:CARLDEVICE",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
|
||||
});
|
||||
|
||||
it("Should support quick re-joiner if keys received out of order", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Simulate Carl leaving then joining back, and key received out of order
|
||||
const initialKey0TimeStamp = 1000;
|
||||
const newKey0TimeStamp = 2000;
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
newKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
initialKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The latest key used for carol should be the one with the latest timestamp
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
0,
|
||||
"@carol:example.org:CAROLDEVICE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("Should only rotate once again if several membership changes during a rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
let members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The initial rollout
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
// Trigger a key rotation with a leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// This should start a new key rollout
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now simulate a new leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// The key `1` rollout is in progress
|
||||
encryptionManager.onMembershipsUpdate(members);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And another one ( plus a joiner)
|
||||
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
getMembershipMock.mockReturnValue(lastMembership);
|
||||
// The key `1` rollout is still in progress
|
||||
encryptionManager.onMembershipsUpdate(lastMembership);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Let all rollouts finish
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// There should 2 rollout. The `1` rollout, then just one additional one
|
||||
// that has "buffered" the 2 membership changes with leavers
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
2,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
// Key `2` should only be distributed to the last membership
|
||||
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
2,
|
||||
// And send only to the last membership
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE3",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate([]);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have beed re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
|
||||
"!room:id",
|
||||
userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
@@ -88,7 +88,9 @@ describe("RoomAndToDeviceTransport", () => {
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
@@ -118,7 +120,9 @@ describe("RoomAndToDeviceTransport", () => {
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
@@ -132,7 +136,11 @@ describe("RoomAndToDeviceTransport", () => {
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership = mockCallMembership(membershipTemplate, roomId, "@alice:example.org");
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { makeMockEvent } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
@@ -61,21 +61,9 @@ describe("ToDeviceKeyTransport", () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
|
||||
roomId,
|
||||
"@bob:example.org",
|
||||
),
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
|
||||
roomId,
|
||||
"@carl:example.org",
|
||||
),
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
|
||||
roomId,
|
||||
"@mat:example.org",
|
||||
),
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
||||
@@ -100,6 +88,7 @@ describe("ToDeviceKeyTransport", () => {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
sent_ts: expect.any(Number),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,11 +142,7 @@ describe("ToDeviceKeyTransport", () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
|
||||
roomId,
|
||||
"@alice:example.org",
|
||||
),
|
||||
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
This file adds a custom test environment for the MembershipManager.spec.ts
|
||||
It can be used with the comment at the top of the file:
|
||||
|
||||
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
|
||||
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
|
||||
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
|
||||
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
|
||||
in their test name.
|
||||
*/
|
||||
|
||||
import { TestEnvironment } from "jest-environment-jsdom";
|
||||
|
||||
import { logger as rootLogger } from "../../../src/logger";
|
||||
const logger = rootLogger.getChild("[MatrixRTCSession]");
|
||||
|
||||
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||
handleTestEvent(event: any) {
|
||||
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
|
||||
let parent = event.test.parent;
|
||||
let isLegacy = false;
|
||||
while (parent) {
|
||||
if (parent.name === "LegacyMembershipManager") {
|
||||
isLegacy = true;
|
||||
break;
|
||||
} else {
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
if (isLegacy) {
|
||||
logger.info("skip test: ", event.test.name);
|
||||
event.test.mode = "skip";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = MemberManagerTestEnvironment;
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2025 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 { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
const CREATOR_USER_ID = "@creator:example.org";
|
||||
const MODERATOR_USER_ID = "@moderator:example.org";
|
||||
|
||||
describe("Room", () => {
|
||||
function createMockClient(): MatrixClient {
|
||||
return {
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
decryptEventIfNeeded: jest.fn().mockReturnThis(),
|
||||
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
}
|
||||
|
||||
function createEvent(eventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: eventId, // we do this for ease of use, not practicality
|
||||
},
|
||||
event_id: eventId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function createRedaction(redactsEventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.redaction",
|
||||
redacts: redactsEventId,
|
||||
event_id: "$redacts_" + redactsEventId.substring(1),
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
|
||||
return room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((e) => !e.isState());
|
||||
}
|
||||
|
||||
it("should apply redactions locally", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const messageEvent = createEvent("$message_event");
|
||||
|
||||
// Set up the room
|
||||
await room.addLiveEvents([messageEvent], { addToState: false });
|
||||
let timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(1);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
|
||||
|
||||
// Now redact
|
||||
const redactionEvent = createRedaction(messageEvent.getId()!);
|
||||
await room.addLiveEvents([redactionEvent], { addToState: false });
|
||||
timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(2);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(true); // test case
|
||||
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
|
||||
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
|
||||
});
|
||||
|
||||
describe("MSC4293: Redact on ban", () => {
|
||||
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
|
||||
// Pre-populate room
|
||||
const messageEvents: MatrixEvent[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
messageEvents.push(createEvent(`$message_${i}`));
|
||||
}
|
||||
await room.addLiveEvents(messageEvents, { addToState: false });
|
||||
|
||||
if (andGrantPermissions) {
|
||||
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return { room, messageEvents };
|
||||
}
|
||||
|
||||
function createRedactOnMembershipChange(
|
||||
targetUserId: string,
|
||||
senderUserId: string,
|
||||
membership: string,
|
||||
): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.member",
|
||||
state_key: targetUserId,
|
||||
content: {
|
||||
"membership": membership,
|
||||
"org.matrix.msc4293.redact_events": true,
|
||||
},
|
||||
sender: senderUserId,
|
||||
});
|
||||
}
|
||||
|
||||
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
|
||||
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
|
||||
messageEvents.find((e2) => e2.getId() === e.getId()),
|
||||
);
|
||||
expect(actualEvents.length).toEqual(messageEvents.length);
|
||||
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
|
||||
if (shouldAllBeRedacted) {
|
||||
expect(redactedEvents.length).toEqual(messageEvents.length);
|
||||
} else {
|
||||
expect(redactedEvents.length).toEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
it("should apply on ban", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should apply on kick", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([kickEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should not apply if the user doesn't have permission to redact", async () => {
|
||||
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to self-leaves", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to invites", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to joins", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to knocks", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TweakName,
|
||||
} from "../../src";
|
||||
import { mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
const msc3914RoomCallRule: IPushRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
@@ -209,7 +210,7 @@ describe("NotificationService", function () {
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules!);
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(logger, matrixClient.pushRules!);
|
||||
pushProcessor = new PushProcessor(matrixClient);
|
||||
});
|
||||
|
||||
@@ -731,7 +732,7 @@ describe("Test PushProcessor.partsForDottedKey", function () {
|
||||
|
||||
describe("rewriteDefaultRules", () => {
|
||||
it("should add default rules in the correct order", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
content: [],
|
||||
@@ -867,7 +868,7 @@ describe("rewriteDefaultRules", () => {
|
||||
});
|
||||
|
||||
it("should add missing msc3914 rule in correct place", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
// Sample push rules from a Synapse user.
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
@@ -55,7 +56,7 @@ describe("CrossSigningIdentity", () => {
|
||||
store: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
crossSigning = new CrossSigningIdentity(logger, olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("KeyClaimManager", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
|
||||
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type UIAuthCallback,
|
||||
} from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
@@ -76,7 +77,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
/* simple requests that map directly to the request body */
|
||||
@@ -293,7 +294,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
return await authRequestResultResolvers.promise;
|
||||
},
|
||||
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
|
||||
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, mockHttpApi);
|
||||
});
|
||||
|
||||
// build a request
|
||||
@@ -325,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
AllDevicesIsolationMode,
|
||||
OnlySignedDevicesIsolationMode,
|
||||
} from "../../../src/crypto-api";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomEncryptor", () => {
|
||||
describe("History Visibility", () => {
|
||||
@@ -108,6 +109,7 @@ describe("RoomEncryptor", () => {
|
||||
} as unknown as Mocked<Room>;
|
||||
|
||||
roomEncryptor = new RoomEncryptor(
|
||||
logger,
|
||||
mockOlmMachine,
|
||||
mockKeyClaimManager,
|
||||
mockOutgoingRequestManager,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/Outgoing
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import * as TestData from "../../test-utils/test-data";
|
||||
import { RustBackupManager, type KeyBackup } from "../../../src/rust-crypto/backup";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("Upload keys to backup", () => {
|
||||
/** The backup manager under test */
|
||||
@@ -63,7 +64,7 @@ describe("Upload keys to backup", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
rustBackupManager = new RustBackupManager(logger, mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
@@ -551,11 +551,11 @@ describe("RustCrypto", () => {
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
|
||||
];
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("should pass through bad encrypted messages", async () => {
|
||||
it("should fail to process bad encrypted messages", async () => {
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
const keys = olmMachine.identityKeys;
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
@@ -576,7 +576,7 @@ describe("RustCrypto", () => {
|
||||
];
|
||||
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
expect(res.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
|
||||
+13
-10
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ToDeviceMessageId } from "./@types/event.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "./client.ts";
|
||||
import { type MatrixError } from "./http-api/index.ts";
|
||||
import {
|
||||
@@ -40,7 +40,10 @@ export class ToDeviceMessageQueue {
|
||||
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryAttempts = 0;
|
||||
|
||||
public constructor(private client: MatrixClient) {}
|
||||
public constructor(
|
||||
private client: MatrixClient,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
this.running = true;
|
||||
@@ -67,7 +70,7 @@ export class ToDeviceMessageQueue {
|
||||
const msgmap = batchWithTxnId.batch.map(
|
||||
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
|
||||
);
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
|
||||
msgmap,
|
||||
);
|
||||
@@ -83,7 +86,7 @@ export class ToDeviceMessageQueue {
|
||||
|
||||
if (this.sending || !this.running) return;
|
||||
|
||||
logger.debug("Attempting to send queued to-device messages");
|
||||
this.logger.debug("Attempting to send queued to-device messages");
|
||||
|
||||
this.sending = true;
|
||||
let headBatch: IndexedToDeviceBatch | null;
|
||||
@@ -99,7 +102,7 @@ export class ToDeviceMessageQueue {
|
||||
// Make sure we're still running after the async tasks: if not, stop.
|
||||
if (!this.running) return;
|
||||
|
||||
logger.debug("All queued to-device messages sent");
|
||||
this.logger.debug("All queued to-device messages sent");
|
||||
} catch (e) {
|
||||
++this.retryAttempts;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -109,15 +112,15 @@ export class ToDeviceMessageQueue {
|
||||
// the scheduler function doesn't differentiate between fatal errors and just getting
|
||||
// bored and giving up for now
|
||||
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
|
||||
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
await this.client.store.removeToDeviceBatch(headBatch!.id);
|
||||
} else {
|
||||
logger.info("Automatic retry limit reached for to-device messages.");
|
||||
this.logger.info("Automatic retry limit reached for to-device messages.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
@@ -133,7 +136,7 @@ export class ToDeviceMessageQueue {
|
||||
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
|
||||
);
|
||||
|
||||
@@ -146,7 +149,7 @@ export class ToDeviceMessageQueue {
|
||||
*/
|
||||
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
|
||||
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
|
||||
logger.info(`Resuming queue after resumed sync`);
|
||||
this.logger.info(`Resuming queue after resumed sync`);
|
||||
this.sendQueue();
|
||||
}
|
||||
};
|
||||
|
||||
+162
-129
@@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t
|
||||
import { type MatrixScheduler } from "./scheduler.ts";
|
||||
import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts";
|
||||
import { type AuthDict } from "./interactive-auth.ts";
|
||||
import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
|
||||
import {
|
||||
type IMinimalEvent,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "./sync-accumulator.ts";
|
||||
import type { EventTimelineSet } from "./models/event-timeline-set.ts";
|
||||
import * as ContentHelpers from "./content-helpers.ts";
|
||||
import {
|
||||
@@ -360,7 +365,7 @@ export interface ICreateClientOpts {
|
||||
* to all requests with this client. Useful for application services which require
|
||||
* `?user_id=`.
|
||||
*/
|
||||
queryParams?: Record<string, string>;
|
||||
queryParams?: QueryDict;
|
||||
|
||||
/**
|
||||
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
|
||||
@@ -883,76 +888,6 @@ interface IWhoamiResponse {
|
||||
const EVENT_ID_PREFIX = "$";
|
||||
|
||||
export enum ClientEvent {
|
||||
Sync = "sync",
|
||||
Event = "event",
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
AccountData = "accountData",
|
||||
Room = "Room",
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the SDK's syncing state is updated. The state can be one of:
|
||||
* <ul>
|
||||
@@ -1035,13 +970,15 @@ export type ClientEventHandlerMap = {
|
||||
* trying to sync after stopClient has been called.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param state - An enum representing the syncing state. One of "PREPARED",
|
||||
* The payloads consits of the following 3 parameters:
|
||||
*
|
||||
* - state - An enum representing the syncing state. One of "PREPARED",
|
||||
* "SYNCING", "ERROR", "STOPPED".
|
||||
*
|
||||
* @param prevState - An enum representing the previous syncing state.
|
||||
* - prevState - An enum representing the previous syncing state.
|
||||
* One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
|
||||
*
|
||||
* @param data - Data about this transition.
|
||||
* - data - Data about this transition.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
@@ -1061,14 +998,14 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
Sync = "sync",
|
||||
/**
|
||||
* Fires whenever the SDK receives a new event.
|
||||
* <p>
|
||||
* This is only fired for live events received via /sync - it is not fired for
|
||||
* events received over context, search, or pagination APIs.
|
||||
*
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("event", function(event){
|
||||
@@ -1076,10 +1013,10 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
/**
|
||||
Event = "event",
|
||||
/** @deprecated Use {@link ReceivedToDeviceMessage}.
|
||||
* Fires whenever the SDK receives a new to-device event.
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event ({@link MatrixEvent}) which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("toDeviceEvent", function(event){
|
||||
@@ -1087,8 +1024,69 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
/**
|
||||
* Fires whenever the SDK receives a new (potentially decrypted) to-device message.
|
||||
* The payload is the to-device message and the encryption info for that message ({@link ReceivedToDeviceMessage}).
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("receivedToDeviceMessage", function(payload){
|
||||
* const { message, encryptionInfo } = payload;
|
||||
* var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender;
|
||||
* var isVerified = encryptionInfo ? encryptionInfo.verified : false;
|
||||
* var type = message.type;
|
||||
* });
|
||||
*/
|
||||
ReceivedToDeviceMessage = "receivedToDeviceMessage",
|
||||
/**
|
||||
* Fires whenever new user-scoped account_data is added.
|
||||
* The payload is a pair of event ({@link MatrixEvent}) describing the account_data just added, and the previous event, if known:
|
||||
* - event: The event describing the account_data just added
|
||||
* - oldEvent: The previous account data, if known.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("accountData", function(event, oldEvent){
|
||||
* myAccountData[event.type] = event.content;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
AccountData = "accountData",
|
||||
/**
|
||||
* Fires whenever a new Room is added. This will fire when you are invited to a
|
||||
* room, as well as when you join a room. <strong>This event is experimental and
|
||||
* may change.</strong>
|
||||
*
|
||||
* The payload is the newly created room, fully populated.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Room", function(room){
|
||||
* var roomId = room.roomId;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
Room = "Room",
|
||||
/**
|
||||
* Fires whenever a Room is removed. This will fire when you forget a room.
|
||||
* <strong>This event is experimental and may change.</strong>
|
||||
* The payload is the roomId of the deleted room.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("deleteRoom", function(roomId){
|
||||
* // update UI from getRooms()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
/**
|
||||
* Fires when the client .well-known info is fetched.
|
||||
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
|
||||
*/
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
/**
|
||||
* @deprecated This event is not supported anymore.
|
||||
*
|
||||
* Fires if a to-device event is received that cannot be decrypted.
|
||||
* Encrypted to-device events will (generally) use plain Olm encryption,
|
||||
* in which case decryption failures are fatal: the event will never be
|
||||
@@ -1097,52 +1095,78 @@ export type ClientEventHandlerMap = {
|
||||
*
|
||||
* An undecryptable to-device event is therefore likely to indicate problems.
|
||||
*
|
||||
* @param event - The undecyptable to-device event
|
||||
* The payload is the undecyptable to-device event
|
||||
*/
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
|
||||
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
/**
|
||||
* Fires whenever new user-scoped account_data is added.
|
||||
* @param event - The event describing the account_data just added
|
||||
* @param event - The previous account data, if known.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("accountData", function(event, oldEvent){
|
||||
* myAccountData[event.type] = event.content;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
|
||||
/**
|
||||
* Fires whenever a new Room is added. This will fire when you are invited to a
|
||||
* room, as well as when you join a room. <strong>This event is experimental and
|
||||
* may change.</strong>
|
||||
* @param room - The newly created, fully populated room.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Room", function(room){
|
||||
* var roomId = room.roomId;
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Room]: (room: Room) => void;
|
||||
/**
|
||||
* Fires whenever a Room is removed. This will fire when you forget a room.
|
||||
* <strong>This event is experimental and may change.</strong>
|
||||
* @param roomId - The deleted room ID.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("deleteRoom", function(roomId){
|
||||
* // update UI from getRooms()
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.DeleteRoom]: (roomId: string) => void;
|
||||
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
|
||||
/**
|
||||
* Fires when the client .well-known info is fetched.
|
||||
*
|
||||
* @param data - The JSON object returned by the server
|
||||
*/
|
||||
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
|
||||
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
|
||||
@@ -1341,9 +1365,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// NB. We initialise MatrixRTC whether we have call support or not: this is just
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this.logger, this);
|
||||
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.http);
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.logger, this.http);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
@@ -1365,7 +1389,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
this.roomNameGenerator = opts.roomNameGenerator;
|
||||
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this, this.logger);
|
||||
|
||||
// The SDK doesn't really provide a clean way for events to recalculate the push
|
||||
// actions for themselves, so we have to kinda help them out when they are encrypted.
|
||||
@@ -1482,6 +1506,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
return this.canResetTimelineCallback(roomId);
|
||||
},
|
||||
logger: this.logger.getChild("sync"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1527,9 +1552,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Clear any data out of the persistent stores used by the client.
|
||||
*
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* @returns Promise which resolves when the stores have been cleared.
|
||||
*/
|
||||
public clearStores(): Promise<void> {
|
||||
public clearStores(
|
||||
args: {
|
||||
cryptoDatabasePrefix?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (this.clientRunning) {
|
||||
throw new Error("Cannot clear stores while client is running");
|
||||
}
|
||||
@@ -1552,8 +1582,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return;
|
||||
}
|
||||
for (const dbname of [
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
]) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
this.logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
@@ -1901,6 +1931,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* ensuring that only one `MatrixClient` issue is instantiated at a time.
|
||||
*
|
||||
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* Unused if useIndexedDB is 'false'.
|
||||
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
|
||||
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
|
||||
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
|
||||
@@ -1914,6 +1946,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public async initRustCrypto(
|
||||
args: {
|
||||
useIndexedDB?: boolean;
|
||||
cryptoDatabasePrefix?: string;
|
||||
storageKey?: Uint8Array;
|
||||
storagePassword?: string;
|
||||
} = {},
|
||||
@@ -1950,7 +1983,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceId: deviceId,
|
||||
secretStorage: this.secretStorage,
|
||||
cryptoCallbacks: this.cryptoCallbacks,
|
||||
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
|
||||
storePrefix: args.useIndexedDB === false ? null : (args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX),
|
||||
storeKey: args.storageKey,
|
||||
storePassphrase: args.storagePassword,
|
||||
|
||||
@@ -7295,7 +7328,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public setPushRules(rules: IPushRules): void {
|
||||
// Fix-up defaults, if applicable.
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(this.logger, rules, this.getUserId()!);
|
||||
// Pre-calculate any necessary caches.
|
||||
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
|
||||
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
|
||||
@@ -96,9 +96,11 @@ export interface SyncCryptoCallbacks {
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
*
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
|
||||
* failed to decrypt, and so will be omitted from the output list.
|
||||
*
|
||||
*/
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop when one time key counts and unused fallback key details are received.
|
||||
|
||||
@@ -1365,6 +1365,27 @@ export interface OwnDeviceKeys {
|
||||
curve25519: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the encryption of a successfully decrypted to-device message.
|
||||
*/
|
||||
export interface OlmEncryptionInfo {
|
||||
/** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/
|
||||
sender: string;
|
||||
/**
|
||||
* The device ID of the device that sent us the event.
|
||||
* Note this is untrusted data unless {@link senderVerified} is true.
|
||||
* If the device ID is not known, this will be `null`.
|
||||
**/
|
||||
senderDevice?: string;
|
||||
/** The sender device's public Curve25519 key, base64 encoded **/
|
||||
senderCurve25519KeyBase64: string;
|
||||
/**
|
||||
* If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified.
|
||||
* This is the state at the time of decryption (the user could be verified later).
|
||||
*/
|
||||
senderVerified: boolean;
|
||||
}
|
||||
|
||||
export * from "./verification.ts";
|
||||
export type * from "./keybackup.ts";
|
||||
export * from "./recovery-key.ts";
|
||||
|
||||
+13
-11
@@ -99,17 +99,19 @@ export class FilterComponent {
|
||||
* Converts the filter component into the form expected over the wire
|
||||
*/
|
||||
public toJSON(): object {
|
||||
return {
|
||||
types: this.filterJson.types || null,
|
||||
not_types: this.filterJson.not_types || [],
|
||||
rooms: this.filterJson.rooms || null,
|
||||
not_rooms: this.filterJson.not_rooms || [],
|
||||
senders: this.filterJson.senders || null,
|
||||
not_senders: this.filterJson.not_senders || [],
|
||||
contains_url: this.filterJson.contains_url || null,
|
||||
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
|
||||
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries({
|
||||
types: this.filterJson.types,
|
||||
not_types: this.filterJson.not_types,
|
||||
rooms: this.filterJson.rooms,
|
||||
not_rooms: this.filterJson.not_rooms,
|
||||
senders: this.filterJson.senders,
|
||||
not_senders: this.filterJson.not_senders,
|
||||
contains_url: this.filterJson.contains_url,
|
||||
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name],
|
||||
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name],
|
||||
}).filter(([_key, value]) => value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -379,9 +379,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
? baseUrlWithFallback.slice(0, -1)
|
||||
: baseUrlWithFallback;
|
||||
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
|
||||
if (queryParams) {
|
||||
encodeParams(queryParams, url.searchParams);
|
||||
// If there are any params, encode and append them to the URL.
|
||||
if (this.opts.extraParams || queryParams) {
|
||||
const mergedParams = { ...this.opts.extraParams, ...queryParams };
|
||||
encodeParams(mergedParams, url.searchParams);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type MatrixError } from "./errors.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
|
||||
export type Body = Record<string, any> | BodyInit;
|
||||
|
||||
@@ -52,7 +53,7 @@ export interface IHttpOpts {
|
||||
baseUrl: string;
|
||||
idBaseUrl?: string;
|
||||
prefix: string;
|
||||
extraParams?: Record<string, string>;
|
||||
extraParams?: QueryDict;
|
||||
|
||||
accessToken?: string;
|
||||
/**
|
||||
|
||||
@@ -160,6 +160,9 @@ function getPrefixedLogger(prefix?: string): PrefixedLogger {
|
||||
/**
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
*
|
||||
* @deprecated avoid the use of this unless you are the constructor of `MatrixClient`: you should be using the logger
|
||||
* associated with `MatrixClient`.
|
||||
*/
|
||||
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type Statistics } from "./types.ts";
|
||||
import { getParticipantId } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
@@ -42,6 +43,10 @@ export interface IEncryptionManager {
|
||||
*
|
||||
* @returns A map where the keys are identifiers and the values are arrays of
|
||||
* objects containing encryption keys and their associated timestamps.
|
||||
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
|
||||
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
|
||||
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
|
||||
* by the key provider. We don't want to add another layer of key storage.
|
||||
*/
|
||||
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
|
||||
}
|
||||
@@ -82,6 +87,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
private logger: Logger;
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
@@ -280,7 +286,18 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
try {
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
|
||||
const targets = this.getMemberships()
|
||||
.filter((membership) => {
|
||||
return membership.sender != undefined;
|
||||
})
|
||||
.map((membership) => {
|
||||
return {
|
||||
userId: membership.sender!,
|
||||
deviceId: membership.deviceId,
|
||||
membershipTs: membership.createdTs(),
|
||||
};
|
||||
});
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
@@ -408,8 +425,6 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
};
|
||||
}
|
||||
|
||||
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
|
||||
|
||||
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
export enum KeyTransportEvents {
|
||||
ReceivedKeys = "received_keys",
|
||||
@@ -45,7 +45,7 @@ export interface IKeyTransport {
|
||||
* @param index
|
||||
* @param members - The participants that should get they key
|
||||
*/
|
||||
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
|
||||
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
|
||||
|
||||
/** Subscribe to keys from this transport. */
|
||||
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 { EventType } from "../@types/event.ts";
|
||||
import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import { HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { type EmptyObject } from "../@types/common.ts";
|
||||
import { Status } from "./types.ts";
|
||||
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
/**
|
||||
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
|
||||
*
|
||||
* Its responsibitiy is to manage the locals user membership:
|
||||
* - send that sate event
|
||||
* - send the delayed leave event
|
||||
* - update the delayed leave event while connected
|
||||
* - update the state event when it times out (for calls longer than membershipExpiryTimeout ~ 4h)
|
||||
*
|
||||
* It is possible to test this class on its own. The api surface (to use for tests) is
|
||||
* defined in `MembershipManagerInterface`.
|
||||
*
|
||||
* It is recommended to only use this interface for testing to allow replacing this class.
|
||||
*
|
||||
* @internal
|
||||
* @deprecated Use {@link MembershipManager} instead
|
||||
*/
|
||||
export class LegacyMembershipManager implements IMembershipManager {
|
||||
private relativeExpiry: number | undefined;
|
||||
|
||||
private memberEventTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
|
||||
*/
|
||||
private ownFociPreferred?: Focus[];
|
||||
/**
|
||||
* This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
|
||||
*/
|
||||
private ownFocusActive?: Focus;
|
||||
|
||||
private updateCallMembershipRunning = false;
|
||||
private needCallMembershipUpdate = false;
|
||||
/**
|
||||
* If the server disallows the configured {@link delayedLeaveEventDelayMs},
|
||||
* this stores a delay that the server does allow.
|
||||
*/
|
||||
private delayedLeaveEventDelayMsOverride?: number;
|
||||
private disconnectDelayId: string | undefined;
|
||||
|
||||
private get networkErrorRetryMs(): number {
|
||||
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
|
||||
}
|
||||
private get membershipEventExpiryMs(): number {
|
||||
return (
|
||||
this.joinConfig?.membershipEventExpiryMs ??
|
||||
this.joinConfig?.membershipExpiryTimeout ??
|
||||
DEFAULT_EXPIRE_DURATION
|
||||
);
|
||||
}
|
||||
private get delayedLeaveEventDelayMs(): number {
|
||||
return (
|
||||
this.delayedLeaveEventDelayMsOverride ??
|
||||
this.joinConfig?.delayedLeaveEventDelayMs ??
|
||||
this.joinConfig?.membershipServerSideExpiryTimeout ??
|
||||
8_000
|
||||
);
|
||||
}
|
||||
private get delayedLeaveEventRestartMs(): number {
|
||||
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private joinConfig: MembershipConfig | undefined,
|
||||
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
| "getUserId"
|
||||
| "getDeviceId"
|
||||
| "sendStateEvent"
|
||||
| "_unstable_sendDelayedStateEvent"
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
) {}
|
||||
|
||||
public off(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("off is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public on(
|
||||
event: MembershipManagerEvent.StatusChanged,
|
||||
listener: (oldStatus: Status, newStatus: Status) => void,
|
||||
): this {
|
||||
logger.error("on is not implemented on LegacyMembershipManager");
|
||||
return this;
|
||||
}
|
||||
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
}
|
||||
public isActivated(): boolean {
|
||||
return this.isJoined();
|
||||
}
|
||||
/**
|
||||
* Unimplemented
|
||||
* @returns Status.Unknown
|
||||
*/
|
||||
public get status(): Status {
|
||||
return Status.Unknown;
|
||||
}
|
||||
|
||||
public join(fociPreferred: Focus[], fociActive?: Focus): void {
|
||||
this.ownFocusActive = fociActive;
|
||||
this.ownFociPreferred = fociPreferred;
|
||||
this.relativeExpiry = this.membershipEventExpiryMs;
|
||||
// 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.
|
||||
void this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
|
||||
public async leave(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
this.relativeExpiry = undefined;
|
||||
this.ownFocusActive = undefined;
|
||||
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
if (timeout) {
|
||||
// The sleep promise returns the string 'timeout' and the membership update void
|
||||
// A success implies that the membership update was quicker then the timeout.
|
||||
const raceResult = await Promise.race([this.triggerCallMembershipEventUpdate(), sleep(timeout, "timeout")]);
|
||||
return raceResult !== "timeout";
|
||||
} else {
|
||||
await this.triggerCallMembershipEventUpdate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
if (this.isJoined() && !memberships.some(isMyMembership)) {
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
// TODO: Should this be awaited? And is there anything to tell the focus?
|
||||
return this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public getActiveFocus(): Focus | undefined {
|
||||
if (this.ownFocusActive) {
|
||||
// A livekit active focus
|
||||
if (isLivekitFocusActive(this.ownFocusActive)) {
|
||||
if (this.ownFocusActive.focus_selection === "oldest_membership") {
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
}
|
||||
} else {
|
||||
// We do not understand the membership format (could be legacy). We default to oldestMembership
|
||||
// Once there are other methods this is a hard error!
|
||||
const oldestMembership = this.getOldestMembership();
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
}
|
||||
|
||||
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
|
||||
// TODO: Should this await on a shared promise?
|
||||
if (this.updateCallMembershipRunning) {
|
||||
this.needCallMembershipUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateCallMembershipRunning = true;
|
||||
try {
|
||||
// if anything triggers an update while the update is running, do another update afterwards
|
||||
do {
|
||||
this.needCallMembershipUpdate = false;
|
||||
await this.updateCallMembershipEvent();
|
||||
} while (this.needCallMembershipUpdate);
|
||||
} finally {
|
||||
this.updateCallMembershipRunning = false;
|
||||
}
|
||||
};
|
||||
private makeNewMembership(deviceId: string): SessionMembershipData | EmptyObject {
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
return this.makeMyMembership(deviceId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
*/
|
||||
private makeMyMembership(deviceId: string): SessionMembershipData {
|
||||
return {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: deviceId,
|
||||
expires: this.relativeExpiry,
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: this.ownFociPreferred ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async updateCallMembershipEvent(): Promise<void> {
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
|
||||
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
|
||||
|
||||
const localUserId = this.client.getUserId();
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
let newContent: EmptyObject | SessionMembershipData = {};
|
||||
// TODO: add back expiary logic to non-legacy events
|
||||
// previously we checked here if the event is timed out and scheduled a check if not.
|
||||
// maybe there is a better way.
|
||||
newContent = this.makeNewMembership(localDeviceId);
|
||||
|
||||
try {
|
||||
if (this.isJoined()) {
|
||||
const stateKey = this.makeMembershipStateKey(localUserId, localDeviceId);
|
||||
const prepareDelayedDisconnection = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await resendIfRateLimited(() =>
|
||||
this.client._unstable_sendDelayedStateEvent(
|
||||
this.room.roomId,
|
||||
{
|
||||
delay: this.delayedLeaveEventDelayMs,
|
||||
},
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{}, // leave event
|
||||
stateKey,
|
||||
),
|
||||
);
|
||||
this.disconnectDelayId = res.delay_id;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof MatrixError &&
|
||||
e.errcode === "M_UNKNOWN" &&
|
||||
e.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
|
||||
) {
|
||||
const maxDelayAllowed = e.data["org.matrix.msc4140.max_delay"];
|
||||
if (
|
||||
typeof maxDelayAllowed === "number" &&
|
||||
this.delayedLeaveEventDelayMs > maxDelayAllowed
|
||||
) {
|
||||
this.delayedLeaveEventDelayMsOverride = maxDelayAllowed;
|
||||
return prepareDelayedDisconnection();
|
||||
}
|
||||
}
|
||||
logger.error("Failed to prepare delayed disconnection event:", e);
|
||||
}
|
||||
};
|
||||
|
||||
await prepareDelayedDisconnection();
|
||||
// Send join event _after_ preparing the delayed disconnection event
|
||||
await resendIfRateLimited(() =>
|
||||
this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, stateKey),
|
||||
);
|
||||
// If sending state cancels your own delayed state, prepare another delayed state
|
||||
// TODO: Remove this once MSC4140 is stable & doesn't cancel own delayed state
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(
|
||||
knownDisconnectDelayId,
|
||||
UpdateDelayedEventAction.Restart,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
|
||||
// If we get a M_NOT_FOUND we prepare a new delayed event.
|
||||
// In other error cases we do not want to prepare anything since we do not have the guarantee, that the
|
||||
// future is not still running.
|
||||
logger.warn("Failed to update delayed disconnection event, prepare it again:", e);
|
||||
this.disconnectDelayId = undefined;
|
||||
await prepareDelayedDisconnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
this.scheduleDelayDisconnection();
|
||||
}
|
||||
// TODO throw or log an error if this.disconnectDelayId === undefined
|
||||
} else {
|
||||
// Not joined
|
||||
let sentDelayedDisconnect = false;
|
||||
if (this.disconnectDelayId !== undefined) {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(
|
||||
knownDisconnectDelayId,
|
||||
UpdateDelayedEventAction.Send,
|
||||
),
|
||||
);
|
||||
sentDelayedDisconnect = true;
|
||||
} catch (e) {
|
||||
logger.error("Failed to send our delayed disconnection event:", e);
|
||||
}
|
||||
this.disconnectDelayId = undefined;
|
||||
}
|
||||
if (!sentDelayedDisconnect) {
|
||||
await resendIfRateLimited(() =>
|
||||
this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{},
|
||||
this.makeMembershipStateKey(localUserId, localDeviceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info("Sent updated call member event.");
|
||||
} catch (e) {
|
||||
const resendDelay = this.networkErrorRetryMs;
|
||||
logger.warn(`Failed to send call member event (retrying in ${resendDelay}): ${e}`);
|
||||
await sleep(resendDelay);
|
||||
await this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDelayDisconnection(): void {
|
||||
this.memberEventTimeout = setTimeout(() => void this.delayDisconnection(), this.delayedLeaveEventRestartMs);
|
||||
}
|
||||
|
||||
private readonly delayDisconnection = async (): Promise<void> => {
|
||||
try {
|
||||
const knownDisconnectDelayId = this.disconnectDelayId!;
|
||||
await resendIfRateLimited(() =>
|
||||
this.client._unstable_updateDelayedEvent(knownDisconnectDelayId, UpdateDelayedEventAction.Restart),
|
||||
);
|
||||
this.scheduleDelayDisconnection();
|
||||
} catch (e) {
|
||||
logger.error("Failed to delay our disconnection event:", e);
|
||||
}
|
||||
};
|
||||
|
||||
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
|
||||
const stateKey = `${localUserId}_${localDeviceId}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
} else {
|
||||
return `_${stateKey}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resendIfRateLimited<T>(func: () => Promise<T>, numRetriesAllowed: number = 1): Promise<T> {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
return await func();
|
||||
} catch (e) {
|
||||
if (numRetriesAllowed > 0 && e instanceof HTTPError && e.isRateLimitError()) {
|
||||
numRetriesAllowed--;
|
||||
let resendDelay: number;
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = e.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
resendDelay = defaultMs;
|
||||
}
|
||||
await sleep(resendDelay);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,13 @@ import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { MembershipManager } from "./MembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
@@ -77,6 +77,7 @@ export interface MembershipConfig {
|
||||
* Use the new Manager.
|
||||
*
|
||||
* Default: `false`.
|
||||
* @deprecated does nothing anymore we always default to the new memberhip manager.
|
||||
*/
|
||||
useNewMembershipManager?: boolean;
|
||||
|
||||
@@ -387,22 +388,19 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager and pass the RTCSession logger (with room id info)
|
||||
if (joinConfig?.useNewMembershipManager ?? false) {
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
}
|
||||
|
||||
this.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Create Encryption manager
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
this.logger.info("Using experimental to-device transport for encryption keys");
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
@@ -413,20 +411,40 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
participantId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client.getUserId()!,
|
||||
this.client.getDeviceId()!,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
// Join!
|
||||
@@ -577,7 +595,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||
|
||||
if (changed) {
|
||||
this.logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
this.logger.info(
|
||||
`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`,
|
||||
);
|
||||
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
@@ -48,8 +48,12 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
// longer the correct session object for the room.
|
||||
private roomSessions = new Map<string, MatrixRTCSession>();
|
||||
|
||||
private logger: Logger;
|
||||
public constructor(private client: MatrixClient) {
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor(
|
||||
rootLogger: Logger,
|
||||
private client: MatrixClient,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
}
|
||||
|
||||
@@ -21,13 +21,12 @@ import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { defer, type IDeferred } from "../utils.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
|
||||
import { type Focus } from "./focus.ts";
|
||||
import { isMyMembership, Status } from "./types.ts";
|
||||
import { isLivekitFocusActive } from "./LivekitFocus.ts";
|
||||
import { type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
@@ -209,14 +208,15 @@ export class MembershipManager
|
||||
// So we do not check scheduler.actions/scheduler.insertions
|
||||
if (!this.leavePromiseResolvers) {
|
||||
// reset scheduled actions so we will not do any new actions.
|
||||
this.leavePromiseResolvers = defer<boolean>();
|
||||
this.leavePromiseResolvers = Promise.withResolvers<boolean>();
|
||||
this.activated = false;
|
||||
this.scheduler.initiateLeave();
|
||||
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
|
||||
}
|
||||
return this.leavePromiseResolvers.promise;
|
||||
}
|
||||
private leavePromiseResolvers?: IDeferred<boolean>;
|
||||
|
||||
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
|
||||
|
||||
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
|
||||
const userId = this.client.getUserId();
|
||||
@@ -229,14 +229,15 @@ export class MembershipManager
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
this.logger.warn("Missing own membership: force re-join");
|
||||
this.state.hasMemberStateEvent = false;
|
||||
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
this.logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
"tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
} else {
|
||||
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
|
||||
this.state.hasMemberStateEvent = false;
|
||||
this.scheduler.initiateJoin();
|
||||
}
|
||||
}
|
||||
@@ -284,7 +285,7 @@ export class MembershipManager
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[MembershipManager]`);
|
||||
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
|
||||
if (userId === null) throw Error("Missing userId in client");
|
||||
if (deviceId === null) throw Error("Missing deviceId in client");
|
||||
@@ -382,13 +383,13 @@ export class MembershipManager
|
||||
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
|
||||
} else {
|
||||
// This can happen if someone else (or another client) removes our own membership event.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
|
||||
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendDelayedEvent`.
|
||||
// We might still have our delayed event from the previous participation and dependent on the server this might not
|
||||
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
|
||||
//
|
||||
// In this block we will try to cancel this delayed event before setting up a new one.
|
||||
|
||||
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
|
||||
return this.cancelKnownDelayIdBeforeSendDelayedEvent(this.state.delayId);
|
||||
}
|
||||
}
|
||||
case MembershipActionType.RestartDelayedEvent: {
|
||||
@@ -488,7 +489,7 @@ export class MembershipManager
|
||||
});
|
||||
}
|
||||
|
||||
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
|
||||
// Remove all running updates and restarts
|
||||
return await this.client
|
||||
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EmptyObject } from "../matrix.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import { MembershipActionType } from "./NewMembershipManager.ts";
|
||||
import { MembershipActionType } from "./MembershipManager.ts";
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
Copyright 2025 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 IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { decodeBase64, encodeBase64 } from "../base64.ts";
|
||||
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
import type {
|
||||
InboundEncryptionSession,
|
||||
OutboundEncryptionSession,
|
||||
ParticipantDeviceInfo,
|
||||
ParticipantId,
|
||||
Statistics,
|
||||
} from "./types.ts";
|
||||
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
*
|
||||
* It is responsible for distributing the keys to the other participants and rotating the keys if needed.
|
||||
*
|
||||
* This manager when used with to-device transport will share the existing key only to new joiners, and rotate
|
||||
* if there is a leaver.
|
||||
*
|
||||
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
|
||||
*/
|
||||
export class RTCEncryptionManager implements IEncryptionManager {
|
||||
// The current per-sender media key for this device
|
||||
private outboundSession: OutboundEncryptionSession | null = null;
|
||||
|
||||
/**
|
||||
* Ensures that there is only one distribute operation at a time for that call.
|
||||
*/
|
||||
private currentKeyDistributionPromise: Promise<void> | null = null;
|
||||
/**
|
||||
* The time to wait before using the outbound session after it has been distributed.
|
||||
* This is to ensure that the key is delivered to all participants before it is used.
|
||||
* When creating the first key, this is set to 0, so that the key can be used immediately.
|
||||
*/
|
||||
private delayRolloutTimeMillis = 1000;
|
||||
/**
|
||||
* If a new key distribution is being requested while one is going on, we will set this flag to true.
|
||||
* This will ensure that a new round is started after the current one.
|
||||
* @private
|
||||
*/
|
||||
private needToEnsureKeyAgain = false;
|
||||
|
||||
/**
|
||||
* There is a possibility that keys arrive in the wrong order.
|
||||
* For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and
|
||||
* if they are received in the wrong order, the stream won't be decryptable.
|
||||
* For that reason we keep a small buffer of keys for a limited time to disambiguate.
|
||||
* @private
|
||||
*/
|
||||
private keyBuffer = new OutdatedKeyFilter();
|
||||
|
||||
private logger: Logger | undefined = undefined;
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
// Callback to notify the media layer of new keys
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: ParticipantId,
|
||||
) => void,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
// This is deprecated should be ignored. Only used by tests?
|
||||
return new Map();
|
||||
}
|
||||
|
||||
public join(joinConfig: EncryptionConfig | undefined): void {
|
||||
this.logger?.info(`Joining room`);
|
||||
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
// Deprecate RoomKeyTransport: this can get removed.
|
||||
if (this.transport instanceof RoomAndToDeviceTransport) {
|
||||
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
|
||||
}
|
||||
|
||||
this.transport.start();
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
}
|
||||
|
||||
// Temporary for backwards compatibility
|
||||
// TODO: Remove this in the future
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.logger?.info("Transport change detected, restarting key distribution");
|
||||
if (this.currentKeyDistributionPromise) {
|
||||
this.currentKeyDistributionPromise
|
||||
.then(() => {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger?.error("Failed to restart key distribution", e);
|
||||
});
|
||||
} else {
|
||||
if (this.outboundSession) {
|
||||
this.outboundSession.sharedWith = [];
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Will ensure that a new key is distributed and used to encrypt our media.
|
||||
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
|
||||
* If this function is called repeatedly while a distribution is in progress,
|
||||
* the calls will be coalesced to a single new distribution (that will start just after the current one has completed).
|
||||
*/
|
||||
private ensureKeyDistribution(): void {
|
||||
if (this.currentKeyDistributionPromise == null) {
|
||||
this.logger?.debug(`No active rollout, start a new one`);
|
||||
// start a rollout
|
||||
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
|
||||
this.logger?.debug(`Rollout completed`);
|
||||
this.currentKeyDistributionPromise = null;
|
||||
if (this.needToEnsureKeyAgain) {
|
||||
this.logger?.debug(`New Rollout needed`);
|
||||
this.needToEnsureKeyAgain = false;
|
||||
// rollout a new one
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
|
||||
// Remember that a new rotation is needed after the current one.
|
||||
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
|
||||
this.needToEnsureKeyAgain = true;
|
||||
}
|
||||
}
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
|
||||
|
||||
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
const keyBin = decodeBase64(keyBase64Encoded);
|
||||
const candidateInboundSession: InboundEncryptionSession = {
|
||||
key: keyBin,
|
||||
participantId,
|
||||
keyIndex: index,
|
||||
creationTS: timestamp,
|
||||
};
|
||||
|
||||
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
|
||||
if (!outdated) {
|
||||
this.onEncryptionKeysChanged(
|
||||
candidateInboundSession.key,
|
||||
candidateInboundSession.keyIndex,
|
||||
candidateInboundSession.participantId,
|
||||
);
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
} else {
|
||||
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the membership of the call changes.
|
||||
* This encryption manager is very basic, it will rotate the key everytime this is called.
|
||||
* @param oldMemberships
|
||||
*/
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
this.logger?.trace(`onMembershipsUpdate`);
|
||||
|
||||
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
|
||||
// If there is an ongoing distribution, it will be completed before a new one is started.
|
||||
this.ensureKeyDistribution();
|
||||
}
|
||||
|
||||
private async rolloutOutboundKey(): Promise<void> {
|
||||
const isFirstKey = this.outboundSession == null;
|
||||
if (isFirstKey) {
|
||||
// create the first key
|
||||
this.outboundSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: 0,
|
||||
};
|
||||
this.onEncryptionKeysChanged(
|
||||
this.outboundSession.key,
|
||||
this.outboundSession.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
);
|
||||
}
|
||||
// get current memberships
|
||||
const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
|
||||
.filter((membership) => {
|
||||
return membership.sender != undefined;
|
||||
})
|
||||
.map((membership) => {
|
||||
return {
|
||||
userId: membership.sender!,
|
||||
deviceId: membership.deviceId,
|
||||
membershipTs: membership.createdTs(),
|
||||
};
|
||||
});
|
||||
|
||||
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
|
||||
|
||||
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
|
||||
// clear their key. Reset the `alreadySharedWith` flag for them.
|
||||
alreadySharedWith = alreadySharedWith.filter(
|
||||
(x) =>
|
||||
// If there was a member with same userId and deviceId but different membershipTs, we need to clear it
|
||||
!toShareWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs,
|
||||
),
|
||||
);
|
||||
|
||||
const anyLeft = alreadySharedWith.filter(
|
||||
(x) =>
|
||||
!toShareWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
|
||||
),
|
||||
);
|
||||
const anyJoined = toShareWith.filter(
|
||||
(x) =>
|
||||
!alreadySharedWith.some(
|
||||
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
|
||||
),
|
||||
);
|
||||
|
||||
let toDistributeTo: ParticipantDeviceInfo[] = [];
|
||||
let outboundKey: OutboundEncryptionSession;
|
||||
let hasKeyChanged = false;
|
||||
if (anyLeft.length > 0) {
|
||||
// We need to rotate the key
|
||||
const newOutboundKey: OutboundEncryptionSession = {
|
||||
key: this.generateRandomKey(),
|
||||
creationTS: Date.now(),
|
||||
sharedWith: [],
|
||||
keyId: this.nextKeyIndex(),
|
||||
};
|
||||
hasKeyChanged = true;
|
||||
|
||||
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
|
||||
// Set this new key as the current one
|
||||
this.outboundSession = newOutboundKey;
|
||||
|
||||
// Send
|
||||
toDistributeTo = toShareWith;
|
||||
outboundKey = newOutboundKey;
|
||||
} else if (anyJoined.length > 0) {
|
||||
// keep the same key
|
||||
// XXX In the future we want to distribute a ratcheted key not the current one
|
||||
toDistributeTo = anyJoined;
|
||||
outboundKey = this.outboundSession!;
|
||||
} else {
|
||||
// no changes
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger?.trace(`Sending key...`);
|
||||
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
outboundKey.sharedWith.push(...toDistributeTo);
|
||||
this.logger?.trace(
|
||||
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
|
||||
);
|
||||
if (hasKeyChanged) {
|
||||
// Delay a bit before using this key
|
||||
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
|
||||
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
|
||||
await sleep(this.delayRolloutTimeMillis);
|
||||
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
|
||||
this.onEncryptionKeysChanged(
|
||||
outboundKey.key,
|
||||
outboundKey.keyId,
|
||||
getParticipantId(this.userId, this.deviceId),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger?.error(`Failed to rollout key`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private nextKeyIndex(): number {
|
||||
if (this.outboundSession) {
|
||||
return (this.outboundSession!.keyId + 1) % 256;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private generateRandomKey(): Uint8Array {
|
||||
const key = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,10 @@ limitations under the License.
|
||||
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type ParticipantDeviceInfo } from "./types.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
@@ -106,7 +106,7 @@ export class RoomAndToDeviceTransport
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
|
||||
@@ -15,13 +15,12 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MatrixClient } from "../client.ts";
|
||||
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
|
||||
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
|
||||
@@ -81,7 +80,7 @@ export class RoomKeyTransport
|
||||
}
|
||||
|
||||
/** implements {@link IKeyTransport#sendKey} */
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
// members not used in room transports as the keys are sent to all room members
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
|
||||
@@ -19,8 +19,7 @@ import { type WidgetApiResponseError } from "matrix-widget-api";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
|
||||
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
|
||||
import { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
@@ -42,6 +41,7 @@ export class ToDeviceKeyTransport
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class ToDeviceKeyTransport
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
const content: EncryptionKeysToDeviceEventContent = {
|
||||
keys: {
|
||||
index: index,
|
||||
@@ -81,24 +81,18 @@ export class ToDeviceKeyTransport
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
},
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
const targets = members
|
||||
.filter((member) => {
|
||||
// filter malformed call members
|
||||
if (member.sender == undefined || member.deviceId == undefined) {
|
||||
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
|
||||
return false;
|
||||
}
|
||||
// Filter out me
|
||||
return !(member.sender == this.userId && member.deviceId == this.deviceId);
|
||||
})
|
||||
.map((member) => {
|
||||
return {
|
||||
userId: member.sender!,
|
||||
userId: member.userId!,
|
||||
deviceId: member.deviceId!,
|
||||
};
|
||||
});
|
||||
})
|
||||
// filter out me
|
||||
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client
|
||||
|
||||
+37
-2
@@ -16,11 +16,44 @@ limitations under the License.
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
|
||||
export type ParticipantId = string;
|
||||
|
||||
export interface EncryptionKeyEntry {
|
||||
index: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mxID, deviceId and membership timestamp of a RTC session participant.
|
||||
*/
|
||||
export type ParticipantDeviceInfo = {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
membershipTs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A type representing the information needed to decrypt video streams.
|
||||
*/
|
||||
export type InboundEncryptionSession = {
|
||||
key: Uint8Array;
|
||||
participantId: ParticipantId;
|
||||
keyIndex: number;
|
||||
creationTS: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The information about the key used to encrypt video streams.
|
||||
*/
|
||||
export type OutboundEncryptionSession = {
|
||||
key: Uint8Array;
|
||||
creationTS: number;
|
||||
// The devices that this key is shared with.
|
||||
sharedWith: Array<ParticipantDeviceInfo>;
|
||||
// This is an index acting as the id of the key
|
||||
keyId: number;
|
||||
};
|
||||
|
||||
export interface EncryptionKeysEventContent {
|
||||
keys: EncryptionKeyEntry[];
|
||||
device_id: string;
|
||||
@@ -28,13 +61,15 @@ export interface EncryptionKeysEventContent {
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* THe content of a to-device event that contains encryption keys.
|
||||
*/
|
||||
export interface EncryptionKeysToDeviceEventContent {
|
||||
keys: { index: number; key: string };
|
||||
member: {
|
||||
// id: ParticipantId,
|
||||
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
|
||||
// Or add some validation on it based on the encryption info
|
||||
claimed_device_id: string;
|
||||
// user_id: string
|
||||
};
|
||||
room_id: string;
|
||||
session: {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2025 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 { InboundEncryptionSession, ParticipantId } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Detects when a key for a given index is outdated.
|
||||
*/
|
||||
export class OutdatedKeyFilter {
|
||||
// Map of participantId -> keyIndex -> timestamp
|
||||
private tsBuffer: Map<ParticipantId, Map<number, number>> = new Map();
|
||||
|
||||
public constructor() {}
|
||||
|
||||
/**
|
||||
* Check if there is a recent key with the same keyId (index) and then use the creationTS to decide what to
|
||||
* do with the key. If the key received is older than the one already in the buffer, it is ignored.
|
||||
* @param participantId
|
||||
* @param item
|
||||
*/
|
||||
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
|
||||
if (!this.tsBuffer.has(participantId)) {
|
||||
this.tsBuffer.set(participantId, new Map<number, number>());
|
||||
}
|
||||
|
||||
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
|
||||
if (latestTimestamp && latestTimestamp > item.creationTS) {
|
||||
// The existing key is more recent, ignore this one
|
||||
return true;
|
||||
}
|
||||
this.tsBuffer.get(participantId)!.set(item.keyIndex, item.creationTS);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
|
||||
return `${userId}:${deviceId}`;
|
||||
}
|
||||
+99
-50
@@ -17,15 +17,15 @@ limitations under the License.
|
||||
import { M_POLL_START, type Optional } from "matrix-events-sdk";
|
||||
|
||||
import {
|
||||
EventTimelineSet,
|
||||
DuplicateStrategy,
|
||||
type IAddLiveEventOptions,
|
||||
EventTimelineSet,
|
||||
type EventTimelineSetHandlerMap,
|
||||
type IAddLiveEventOptions,
|
||||
} from "./event-timeline-set.ts";
|
||||
import { Direction, EventTimeline } from "./event-timeline.ts";
|
||||
import { getHttpUriForMxc } from "../content-repo.ts";
|
||||
import { removeElement } from "../utils.ts";
|
||||
import { normalize, noUnsafeEventProps } from "../utils.ts";
|
||||
import * as utils from "../utils.ts";
|
||||
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
|
||||
import {
|
||||
type IEvent,
|
||||
type IThreadBundledRelationship,
|
||||
@@ -35,17 +35,17 @@ import {
|
||||
} from "./event.ts";
|
||||
import { EventStatus } from "./event-status.ts";
|
||||
import { RoomMember } from "./room-member.ts";
|
||||
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
|
||||
import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import {
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
EventType,
|
||||
RelationType,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
||||
} from "../@types/event.ts";
|
||||
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
|
||||
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
|
||||
@@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts";
|
||||
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
|
||||
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
|
||||
import {
|
||||
FILTER_RELATED_BY_REL_TYPES,
|
||||
FILTER_RELATED_BY_SENDERS,
|
||||
Thread,
|
||||
THREAD_RELATION_TYPE,
|
||||
ThreadEvent,
|
||||
type ThreadEventHandlerMap as ThreadHandlerMap,
|
||||
FILTER_RELATED_BY_REL_TYPES,
|
||||
THREAD_RELATION_TYPE,
|
||||
FILTER_RELATED_BY_SENDERS,
|
||||
ThreadFilterType,
|
||||
} from "./thread.ts";
|
||||
import {
|
||||
@@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts";
|
||||
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
|
||||
import { RoomReceipts } from "./room-receipts.ts";
|
||||
import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||
import * as utils from "../utils.ts";
|
||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||
@@ -2469,7 +2468,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Adds events to a thread's timeline. Will fire "Thread.update"
|
||||
*/
|
||||
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
events.forEach(this.applyRedaction);
|
||||
events.forEach(this.tryApplyRedaction);
|
||||
|
||||
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
|
||||
for (const event of events) {
|
||||
@@ -2580,55 +2579,105 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return thread;
|
||||
}
|
||||
|
||||
private applyRedaction = (event: MatrixEvent): void => {
|
||||
/**
|
||||
* Applies an event as a redaction of another event, regardless of whether the redacting
|
||||
* event is actually a redaction.
|
||||
*
|
||||
* Callers should use tryApplyRedaction instead.
|
||||
*
|
||||
* @param redactionEvent The event which redacts an event.
|
||||
* @param redactedEvent The event being redacted.
|
||||
* @private
|
||||
*/
|
||||
private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void {
|
||||
const threadRootId = redactedEvent.threadRootId;
|
||||
redactedEvent.makeRedacted(redactionEvent, this);
|
||||
|
||||
// If this is in the current state, replace it with the redacted version
|
||||
if (redactedEvent.isState()) {
|
||||
const currentStateEvent = this.currentState.getStateEvents(
|
||||
redactedEvent.getType(),
|
||||
redactedEvent.getStateKey()!,
|
||||
);
|
||||
if (currentStateEvent?.getId() === redactedEvent.getId()) {
|
||||
this.currentState.setStateEvents([redactedEvent]);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId);
|
||||
|
||||
// TODO: we stash user displaynames (among other things) in
|
||||
// RoomMember objects which are then attached to other events
|
||||
// (in the sender and target fields). We should get those
|
||||
// RoomMember objects to update themselves when the events that
|
||||
// they are based on are changed.
|
||||
|
||||
// Remove any visibility change on this event.
|
||||
this.visibilityEvents.delete(redactedEvent.getId()!);
|
||||
|
||||
// If this event is a visibility change event, remove it from the
|
||||
// list of visibility changes and update any event affected by it.
|
||||
if (redactedEvent.isVisibilityEvent()) {
|
||||
this.redactVisibilityChangeEvent(redactionEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private tryApplyRedaction = (event: MatrixEvent): void => {
|
||||
// FIXME: apply redactions to notification list
|
||||
|
||||
// NB: We continue to add the redaction event to the timeline at the
|
||||
// end of this function so clients can say "so and so redacted an event"
|
||||
// if they wish to. Also this may be needed to trigger an update.
|
||||
|
||||
if (event.isRedaction()) {
|
||||
const redactId = event.event.redacts;
|
||||
|
||||
// if we know about this event, redact its contents now.
|
||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||
if (redactedEvent) {
|
||||
const threadRootId = redactedEvent.threadRootId;
|
||||
redactedEvent.makeRedacted(event, this);
|
||||
|
||||
// If this is in the current state, replace it with the redacted version
|
||||
if (redactedEvent.isState()) {
|
||||
const currentStateEvent = this.currentState.getStateEvents(
|
||||
redactedEvent.getType(),
|
||||
redactedEvent.getStateKey()!,
|
||||
);
|
||||
if (currentStateEvent?.getId() === redactedEvent.getId()) {
|
||||
this.currentState.setStateEvents([redactedEvent]);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(RoomEvent.Redaction, event, this, threadRootId);
|
||||
|
||||
// TODO: we stash user displaynames (among other things) in
|
||||
// RoomMember objects which are then attached to other events
|
||||
// (in the sender and target fields). We should get those
|
||||
// RoomMember objects to update themselves when the events that
|
||||
// they are based on are changed.
|
||||
|
||||
// Remove any visibility change on this event.
|
||||
this.visibilityEvents.delete(redactId!);
|
||||
|
||||
// If this event is a visibility change event, remove it from the
|
||||
// list of visibility changes and update any event affected by it.
|
||||
if (redactedEvent.isVisibilityEvent()) {
|
||||
this.redactVisibilityChangeEvent(event);
|
||||
}
|
||||
this.applyEventAsRedaction(event, redactedEvent);
|
||||
}
|
||||
} else if (event.getType() === EventType.RoomMember) {
|
||||
const membership = event.getContent()["membership"];
|
||||
if (
|
||||
membership !== KnownMembership.Ban &&
|
||||
!(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())
|
||||
) {
|
||||
// Not a ban or kick, therefore not a membership event we care about here.
|
||||
return;
|
||||
}
|
||||
const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"];
|
||||
if (redactEvents !== true) {
|
||||
// Invalid or not set - nothing to redact.
|
||||
return;
|
||||
}
|
||||
const state = this.getLiveTimeline().getState(Direction.Forward)!;
|
||||
if (!state.maySendRedactionForEvent(event, event.getSender()!)) {
|
||||
// If the sender can't redact the membership event, then they won't be able to
|
||||
// redact any of the target's events either, so skip.
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: apply redactions to notification list
|
||||
|
||||
// NB: We continue to add the redaction event to the timeline so
|
||||
// clients can say "so and so redacted an event" if they wish to. Also
|
||||
// this may be needed to trigger an update.
|
||||
// The redaction is possible, so let's find all the events and apply it.
|
||||
const events = this.getTimelineSets()
|
||||
.map((s) => s.getTimelines())
|
||||
.reduce((p, c) => {
|
||||
p.push(...c);
|
||||
return p;
|
||||
}, [])
|
||||
.map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey()))
|
||||
.reduce((p, c) => {
|
||||
p.push(...c);
|
||||
return c;
|
||||
}, []);
|
||||
for (const toRedact of events) {
|
||||
this.applyEventAsRedaction(event, toRedact);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private processLiveEvent(event: MatrixEvent): void {
|
||||
this.applyRedaction(event);
|
||||
this.tryApplyRedaction(event);
|
||||
|
||||
// Implement MSC3531: hiding messages.
|
||||
if (event.isVisibilityEvent()) {
|
||||
|
||||
+12
-2
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
import { type MatrixClient } from "./client.ts";
|
||||
import { type MatrixEvent } from "./models/event.ts";
|
||||
import {
|
||||
@@ -170,6 +170,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
|
||||
/**
|
||||
* Make sure that each of the rules listed in `defaultRuleIds` is listed in the given set of push rules.
|
||||
*
|
||||
* @param logger - A `Logger` to write log messages to.
|
||||
* @param kind - the kind of push rule set being merged.
|
||||
* @param incomingRules - the existing set of known push rules for the user.
|
||||
* @param defaultRules - a lookup table for the default definitions of push rules.
|
||||
@@ -178,6 +179,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
|
||||
* @returns A copy of `incomingRules`, with any missing default rules inserted in the right place.
|
||||
*/
|
||||
function mergeRulesWithDefaults(
|
||||
logger: Logger,
|
||||
kind: PushRuleKind,
|
||||
incomingRules: IPushRule[],
|
||||
defaultRules: Record<string, IPushRule>,
|
||||
@@ -276,11 +278,17 @@ export class PushProcessor {
|
||||
* Rewrites conditions on a client's push rules to match the defaults
|
||||
* where applicable. Useful for upgrading push rules to more strict
|
||||
* conditions when the server is falling behind on defaults.
|
||||
*
|
||||
* @param logger - A `Logger` to write log messages to.
|
||||
* @param incomingRules - The client's existing push rules
|
||||
* @param userId - The Matrix ID of the client.
|
||||
* @returns The rewritten rules
|
||||
*/
|
||||
public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules {
|
||||
public static rewriteDefaultRules(
|
||||
logger: Logger,
|
||||
incomingRules: IPushRules,
|
||||
userId: string | undefined = undefined,
|
||||
): IPushRules {
|
||||
let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
|
||||
|
||||
// These lines are mostly to make the tests happy. We shouldn't run into these
|
||||
@@ -292,6 +300,7 @@ export class PushProcessor {
|
||||
|
||||
// Merge the client-level defaults with the ones from the server
|
||||
newRules.global.override = mergeRulesWithDefaults(
|
||||
logger,
|
||||
PushRuleKind.Override,
|
||||
newRules.global.override,
|
||||
DEFAULT_OVERRIDE_RULES,
|
||||
@@ -299,6 +308,7 @@ export class PushProcessor {
|
||||
);
|
||||
|
||||
newRules.global.underride = mergeRulesWithDefaults(
|
||||
logger,
|
||||
PushRuleKind.Underride,
|
||||
newRules.global.underride,
|
||||
DEFAULT_UNDERRIDE_RULES,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
|
||||
import type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { type BootstrapCrossSigningOpts } from "../crypto-api/index.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { type ServerSideSecretStorage } from "../secret-storage.ts";
|
||||
@@ -33,6 +33,7 @@ import { type ServerSideSecretStorage } from "../secret-storage.ts";
|
||||
*/
|
||||
export class CrossSigningIdentity {
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
@@ -61,7 +62,7 @@ export class CrossSigningIdentity {
|
||||
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
|
||||
|
||||
// Log all relevant state for easier parsing of debug logs.
|
||||
logger.log("bootstrapCrossSigning: starting", {
|
||||
this.logger.debug("bootstrapCrossSigning: starting", {
|
||||
setupNewCrossSigning: opts.setupNewCrossSigning,
|
||||
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
|
||||
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
|
||||
@@ -71,23 +72,23 @@ export class CrossSigningIdentity {
|
||||
|
||||
if (olmDeviceHasKeys) {
|
||||
if (!(await this.secretStorage.hasKey())) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"bootstrapCrossSigning: Olm device has private keys, but secret storage is not yet set up; doing nothing for now.",
|
||||
);
|
||||
// the keys should get uploaded to 4S once that is set up.
|
||||
} else if (!privateKeysInSecretStorage) {
|
||||
// the device has the keys but they are not in 4S, so update it
|
||||
logger.log("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
this.logger.debug("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
} else {
|
||||
logger.log(
|
||||
this.logger.debug(
|
||||
"bootstrapCrossSigning: Olm device has private keys and they are saved in secret storage; doing nothing",
|
||||
);
|
||||
}
|
||||
} /* (!olmDeviceHasKeys) */ else {
|
||||
if (privateKeysInSecretStorage) {
|
||||
// they are in 4S, so import from there
|
||||
logger.log(
|
||||
this.logger.debug(
|
||||
"bootstrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"in secret storage, reading storage and caching locally",
|
||||
);
|
||||
@@ -116,7 +117,7 @@ export class CrossSigningIdentity {
|
||||
device.free();
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
this.logger.debug(
|
||||
"bootstrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
|
||||
);
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
@@ -125,7 +126,7 @@ export class CrossSigningIdentity {
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
|
||||
logger.log("bootstrapCrossSigning: complete");
|
||||
this.logger.debug("bootstrapCrossSigning: complete");
|
||||
}
|
||||
|
||||
/** Reset our cross-signing keys
|
||||
@@ -142,7 +143,7 @@ export class CrossSigningIdentity {
|
||||
|
||||
// If 4S is configured we need to update it.
|
||||
if (!(await this.secretStorage.hasKey())) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"resetCrossSigning: Secret storage is not yet set up; not exporting keys to secret storage yet.",
|
||||
);
|
||||
// the keys should get uploaded to 4S once that is set up.
|
||||
@@ -150,11 +151,11 @@ export class CrossSigningIdentity {
|
||||
// Update 4S before uploading cross-signing keys, to stay consistent with legacy that asks
|
||||
// 4S passphrase before asking for account password.
|
||||
// Ultimately should be made atomic and resistant to forgotten password/passphrase.
|
||||
logger.log("resetCrossSigning: exporting private keys to secret storage");
|
||||
this.logger.debug("resetCrossSigning: exporting private keys to secret storage");
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
}
|
||||
|
||||
logger.log("resetCrossSigning: publishing public keys to server");
|
||||
this.logger.debug("resetCrossSigning: publishing public keys to server");
|
||||
for (const req of [
|
||||
outgoingRequests.uploadKeysRequest,
|
||||
outgoingRequests.uploadSigningKeysRequest,
|
||||
@@ -178,17 +179,17 @@ export class CrossSigningIdentity {
|
||||
if (exported?.masterKey) {
|
||||
await this.secretStorage.store("m.cross_signing.master", exported.masterKey);
|
||||
} else {
|
||||
logger.error(`Cannot export MSK to secret storage, private key unknown`);
|
||||
this.logger.error(`Cannot export MSK to secret storage, private key unknown`);
|
||||
}
|
||||
if (exported?.self_signing_key) {
|
||||
await this.secretStorage.store("m.cross_signing.self_signing", exported.self_signing_key);
|
||||
} else {
|
||||
logger.error(`Cannot export SSK to secret storage, private key unknown`);
|
||||
this.logger.error(`Cannot export SSK to secret storage, private key unknown`);
|
||||
}
|
||||
if (exported?.userSigningKey) {
|
||||
await this.secretStorage.store("m.cross_signing.user_signing", exported.userSigningKey);
|
||||
} else {
|
||||
logger.error(`Cannot export USK to secret storage, private key unknown`);
|
||||
this.logger.error(`Cannot export USK to secret storage, private key unknown`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
UploadSigningKeysRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { logger } from "../logger.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { calculateRetryBackoff, type IHttpOpts, type MatrixHttpApi, Method } from "../http-api/index.ts";
|
||||
import { logDuration, type QueryDict, sleep } from "../utils.ts";
|
||||
import { type AuthDict, type UIAuthCallback } from "../interactive-auth.ts";
|
||||
@@ -49,6 +49,7 @@ import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceM
|
||||
*/
|
||||
export class OutgoingRequestProcessor {
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
@@ -100,13 +101,13 @@ export class OutgoingRequestProcessor {
|
||||
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
this.logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
try {
|
||||
await logDuration(logger, `Mark Request as sent ${msg.type}`, async () => {
|
||||
await logDuration(this.logger, `Mark Request as sent ${msg.type}`, async () => {
|
||||
await this.olmMachine.markRequestAsSent(msg.id!, msg.type, resp);
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -116,13 +117,13 @@ export class OutgoingRequestProcessor {
|
||||
e instanceof Error &&
|
||||
(e.message === "Attempt to use a moved value" || e.message === "null pointer passed to rust")
|
||||
) {
|
||||
logger.log(`Ignoring error '${e.message}': client is likely shutting down`);
|
||||
this.logger.debug(`Ignoring error '${e.message}': client is likely shutting down`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.trace(`Outgoing request type:${msg.type} does not have an ID`);
|
||||
this.logger.trace(`Outgoing request type:${msg.type} does not have an ID`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Sending batch of to-device messages. type=${request.event_type} txnid=${request.txn_id}`,
|
||||
messageList,
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type Logger, logger, LogSpan } from "../logger.ts";
|
||||
import { type Logger, LogSpan } from "../logger.ts";
|
||||
import { type KeyClaimManager } from "./KeyClaimManager.ts";
|
||||
import { type RoomMember } from "../models/room-member.ts";
|
||||
import { HistoryVisibility } from "../@types/partials.ts";
|
||||
@@ -44,8 +44,6 @@ import { type DeviceIsolationMode, DeviceIsolationModeKind } from "../crypto-api
|
||||
* @internal
|
||||
*/
|
||||
export class RoomEncryptor {
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
/** whether the room members have been loaded and tracked for the first time */
|
||||
private lazyLoadedMembersResolved = false;
|
||||
|
||||
@@ -57,6 +55,7 @@ export class RoomEncryptor {
|
||||
private currentEncryptionPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* @param prefixedLogger - A logger to use for log messages.
|
||||
* @param olmMachine - The rust-sdk's OlmMachine
|
||||
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
|
||||
* @param outgoingRequestManager - The OutgoingRequestManager, which manages the queue of outgoing requests.
|
||||
@@ -64,14 +63,13 @@ export class RoomEncryptor {
|
||||
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
|
||||
*/
|
||||
public constructor(
|
||||
private readonly prefixedLogger: Logger,
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly keyClaimManager: KeyClaimManager,
|
||||
private readonly outgoingRequestManager: OutgoingRequestsManager,
|
||||
private readonly room: Room,
|
||||
private encryptionSettings: IContent,
|
||||
) {
|
||||
this.prefixedLogger = logger.getChild(`[${room.roomId} encryption]`);
|
||||
|
||||
// start tracking devices for any users already known to be in this room.
|
||||
// Do not load members here, would defeat lazy loading.
|
||||
const members = room.getJoinedMembers();
|
||||
@@ -212,7 +210,7 @@ export class RoomEncryptor {
|
||||
// This could end up being racy (if two calls to ensureEncryptionSession happen at the same time), but that's
|
||||
// not a particular problem, since `OlmMachine.updateTrackedUsers` just adds any users that weren't already tracked.
|
||||
if (!this.lazyLoadedMembersResolved) {
|
||||
await logDuration(this.prefixedLogger, "loadMembersIfNeeded: updateTrackedUsers", async () => {
|
||||
await logDuration(logger, "loadMembersIfNeeded: updateTrackedUsers", async () => {
|
||||
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
|
||||
});
|
||||
logger.debug(`Updated tracked users`);
|
||||
@@ -229,7 +227,7 @@ export class RoomEncryptor {
|
||||
// XXX future improvement process only KeysQueryRequests for the users that have never been queried.
|
||||
logger.debug(`Processing outgoing requests`);
|
||||
|
||||
await logDuration(this.prefixedLogger, "doProcessOutgoingRequests", async () => {
|
||||
await logDuration(logger, "doProcessOutgoingRequests", async () => {
|
||||
await this.outgoingRequestManager.doProcessOutgoingRequests();
|
||||
});
|
||||
} else {
|
||||
@@ -250,7 +248,7 @@ export class RoomEncryptor {
|
||||
|
||||
const userList = members.map((u) => new UserId(u.userId));
|
||||
|
||||
await logDuration(this.prefixedLogger, "ensureSessionsForUsers", async () => {
|
||||
await logDuration(logger, "ensureSessionsForUsers", async () => {
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, userList);
|
||||
});
|
||||
|
||||
@@ -289,7 +287,7 @@ export class RoomEncryptor {
|
||||
break;
|
||||
}
|
||||
|
||||
await logDuration(this.prefixedLogger, "shareRoomKey", async () => {
|
||||
await logDuration(logger, "shareRoomKey", async () => {
|
||||
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
|
||||
new RoomId(this.room.roomId),
|
||||
// safe to pass without cloning, as it's not reused here (before or after)
|
||||
|
||||
+58
-51
@@ -28,7 +28,7 @@ import {
|
||||
type KeyBackupRestoreResult,
|
||||
type KeyBackupRoomSessions,
|
||||
} from "../crypto-api/keybackup.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { ClientPrefix, type IHttpOpts, MatrixError, type MatrixHttpApi, Method } from "../http-api/index.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { encodeUri, logDuration } from "../utils.ts";
|
||||
@@ -81,6 +81,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
private backupKeysLoopRunning = false;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
@@ -131,7 +132,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
const decryptionKey = backupKeys?.decryptionKey;
|
||||
const backupMatchesSavedPrivateKey =
|
||||
!!decryptionKey && backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
|
||||
!!decryptionKey && this.backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
|
||||
return {
|
||||
matchesDecryptionKey: backupMatchesSavedPrivateKey,
|
||||
trusted: signatureVerification.trusted(),
|
||||
@@ -171,14 +172,14 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
try {
|
||||
latestBackupInfo = await this.requestKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.warn("handleBackupSecretReceived: Error checking for latest key backup", e);
|
||||
this.logger.warn("handleBackupSecretReceived: Error checking for latest key backup", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!latestBackupInfo?.version) {
|
||||
// There is no server-side key backup.
|
||||
// This decryption key is useless to us.
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
|
||||
);
|
||||
return false;
|
||||
@@ -186,21 +187,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
try {
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
|
||||
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
|
||||
const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
|
||||
if (!privateKeyMatches) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
|
||||
);
|
||||
// just ignore the secret
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
|
||||
);
|
||||
await this.saveBackupDecryptionKey(backupDecryptionKey, latestBackupInfo.version);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
|
||||
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -282,19 +283,19 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
/** Helper for `checkKeyBackup` */
|
||||
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
|
||||
logger.log("Checking key backup status...");
|
||||
this.logger.debug("Checking key backup status...");
|
||||
let backupInfo: KeyBackupInfo | null | undefined;
|
||||
try {
|
||||
backupInfo = await this.requestKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.warn("Error checking for active key backup", e);
|
||||
this.logger.warn("Error checking for active key backup", e);
|
||||
this.serverBackupInfo = undefined;
|
||||
return null;
|
||||
}
|
||||
this.checkedForBackup = true;
|
||||
|
||||
if (backupInfo && !backupInfo.version) {
|
||||
logger.warn("active backup lacks a useful 'version'; ignoring it");
|
||||
this.logger.warn("active backup lacks a useful 'version'; ignoring it");
|
||||
backupInfo = undefined;
|
||||
}
|
||||
this.serverBackupInfo = backupInfo;
|
||||
@@ -303,10 +304,10 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
if (!backupInfo) {
|
||||
if (activeVersion !== null) {
|
||||
logger.log("No key backup present on server: disabling key backup");
|
||||
this.logger.debug("No key backup present on server: disabling key backup");
|
||||
await this.disableKeyBackup();
|
||||
} else {
|
||||
logger.log("No key backup present on server: not enabling key backup");
|
||||
this.logger.debug("No key backup present on server: not enabling key backup");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -317,23 +318,25 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
// (b) the public key matches the private decryption key that we have received from 4S.
|
||||
if (!trustInfo.matchesDecryptionKey && !trustInfo.trusted) {
|
||||
if (activeVersion !== null) {
|
||||
logger.log("Key backup present on server but not trusted: disabling key backup");
|
||||
this.logger.debug("Key backup present on server but not trusted: disabling key backup");
|
||||
await this.disableKeyBackup();
|
||||
} else {
|
||||
logger.log("Key backup present on server but not trusted: not enabling key backup");
|
||||
this.logger.debug("Key backup present on server but not trusted: not enabling key backup");
|
||||
}
|
||||
} else {
|
||||
if (activeVersion === null) {
|
||||
logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`);
|
||||
this.logger.debug(`Found usable key backup v${backupInfo.version}: enabling key backups`);
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
} else if (activeVersion !== backupInfo.version) {
|
||||
logger.log(`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`);
|
||||
this.logger.debug(
|
||||
`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`,
|
||||
);
|
||||
// This will remove any pending backup request, remove the backup key and reset the backup state of each room key we have.
|
||||
await this.disableKeyBackup();
|
||||
// Enabling will now trigger re-upload of all the keys
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
} else {
|
||||
logger.log(`Backup version ${backupInfo.version} still current`);
|
||||
this.logger.debug(`Backup version ${backupInfo.version} still current`);
|
||||
}
|
||||
}
|
||||
return { backupInfo, trustInfo };
|
||||
@@ -374,12 +377,12 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
private async backupKeysLoop(maxDelay = 10000): Promise<void> {
|
||||
if (this.backupKeysLoopRunning) {
|
||||
logger.log(`Backup loop already running`);
|
||||
this.logger.debug(`Backup loop already running`);
|
||||
return;
|
||||
}
|
||||
this.backupKeysLoopRunning = true;
|
||||
|
||||
logger.log(`Backup: Starting keys upload loop for backup version:${this.activeBackupVersion}.`);
|
||||
this.logger.debug(`Backup: Starting keys upload loop for backup version:${this.activeBackupVersion}.`);
|
||||
|
||||
// wait between 0 and `maxDelay` seconds, to avoid backup
|
||||
// requests from different clients hitting the server all at
|
||||
@@ -401,18 +404,18 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
let request: RustSdkCryptoJs.KeysBackupRequest | undefined = undefined;
|
||||
try {
|
||||
request = await logDuration(
|
||||
logger,
|
||||
this.logger,
|
||||
"BackupRoomKeys: Get keys to backup from rust crypto-sdk",
|
||||
async () => {
|
||||
return await this.olmMachine.backupRoomKeys();
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Backup: Failed to get keys to backup from rust crypto-sdk", err);
|
||||
this.logger.error("Backup: Failed to get keys to backup from rust crypto-sdk", err);
|
||||
}
|
||||
|
||||
if (!request || this.stopped || !this.activeBackupVersion) {
|
||||
logger.log(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
|
||||
this.logger.debug(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
|
||||
if (!request) {
|
||||
// nothing more to upload
|
||||
this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0);
|
||||
@@ -439,7 +442,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
const keyCount = await this.olmMachine.roomKeyCounts();
|
||||
remainingToUploadCount = keyCount.total - keyCount.backedUp;
|
||||
} catch (err) {
|
||||
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
|
||||
this.logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,15 +458,15 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
}
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
logger.error("Backup: Error processing backup request for rust crypto-sdk", err);
|
||||
this.logger.error("Backup: Error processing backup request for rust crypto-sdk", err);
|
||||
if (err instanceof MatrixError) {
|
||||
const errCode = err.data.errcode;
|
||||
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
||||
logger.log(`Backup: Failed to upload keys to current vesion: ${errCode}.`);
|
||||
this.logger.debug(`Backup: Failed to upload keys to current vesion: ${errCode}.`);
|
||||
try {
|
||||
await this.disableKeyBackup();
|
||||
} catch (error) {
|
||||
logger.error("Backup: An error occurred while disabling key backup:", error);
|
||||
this.logger.error("Backup: An error occurred while disabling key backup:", error);
|
||||
}
|
||||
this.emit(CryptoEvent.KeyBackupFailed, err.data.errcode!);
|
||||
// There was an active backup and we are out of sync with the server
|
||||
@@ -480,7 +483,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Backup: An error occurred while retrieving a rate-limit retry delay",
|
||||
error,
|
||||
);
|
||||
@@ -590,7 +593,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
* @param version - The backup version to delete.
|
||||
*/
|
||||
public async deleteKeyBackupVersion(version: string): Promise<void> {
|
||||
logger.debug(`deleteKeyBackupVersion v:${version}`);
|
||||
this.logger.debug(`deleteKeyBackupVersion v:${version}`);
|
||||
const path = encodeUri("/room_keys/version/$version", { $version: version });
|
||||
await this.http.authedRequest<void>(Method.Delete, path, undefined, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
@@ -607,7 +610,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
* @param decryptionKey - The private key to use for decryption.
|
||||
*/
|
||||
public createBackupDecryptor(decryptionKey: RustSdkCryptoJs.BackupDecryptionKey): BackupDecryptor {
|
||||
return new RustBackupDecryptor(decryptionKey);
|
||||
return new RustBackupDecryptor(this.logger, decryptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -707,7 +710,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
totalFailures += currentChunk.length;
|
||||
// We failed to import some keys, but we should still try to import the rest?
|
||||
// Log the error and continue
|
||||
logger.error("Error importing keys from backup", e);
|
||||
this.logger.error("Error importing keys from backup", e);
|
||||
}
|
||||
|
||||
opts?.progressCallback?.({
|
||||
@@ -755,27 +758,28 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
return { total: totalKeyCount, imported: totalImported };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided backup info matches the given private key.
|
||||
*
|
||||
* @param info - The backup info to check.
|
||||
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
|
||||
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
|
||||
*/
|
||||
function backupInfoMatchesBackupDecryptionKey(
|
||||
info: KeyBackupInfo,
|
||||
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
|
||||
): boolean {
|
||||
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
|
||||
return false;
|
||||
/**
|
||||
* Checks if the provided backup info matches the given private key.
|
||||
*
|
||||
* @param info - The backup info to check.
|
||||
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
|
||||
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
|
||||
*/
|
||||
private backupInfoMatchesBackupDecryptionKey(
|
||||
info: KeyBackupInfo,
|
||||
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
|
||||
): boolean {
|
||||
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
this.logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64
|
||||
);
|
||||
}
|
||||
|
||||
return (info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link BackupDecryptor} for the rust crypto backend.
|
||||
*/
|
||||
@@ -783,7 +787,10 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
private decryptionKey: RustSdkCryptoJs.BackupDecryptionKey;
|
||||
public sourceTrusted: boolean;
|
||||
|
||||
public constructor(decryptionKey: RustSdkCryptoJs.BackupDecryptionKey) {
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
|
||||
) {
|
||||
this.decryptionKey = decryptionKey;
|
||||
this.sourceTrusted = false;
|
||||
}
|
||||
@@ -807,7 +814,7 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
decrypted.session_id = sessionId;
|
||||
keys.push(decrypted);
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
this.logger.debug("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
|
||||
@@ -19,7 +19,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
|
||||
import { KnownMembership } from "../@types/membership.ts";
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
|
||||
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts";
|
||||
import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
@@ -35,7 +35,7 @@ import { type IHttpOpts, type MatrixHttpApi, Method } from "../http-api/index.ts
|
||||
import { RoomEncryptor } from "./RoomEncryptor.ts";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
||||
import { KeyClaimManager } from "./KeyClaimManager.ts";
|
||||
import { logDuration, MapWithDefault } from "../utils.ts";
|
||||
import { MapWithDefault } from "../utils.ts";
|
||||
import {
|
||||
type BackupTrustInfo,
|
||||
type BootstrapCrossSigningOpts,
|
||||
@@ -163,7 +163,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
private readonly cryptoCallbacks: CryptoCallbacks,
|
||||
) {
|
||||
super();
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, http);
|
||||
this.outgoingRequestsManager = new OutgoingRequestsManager(
|
||||
this.logger,
|
||||
olmMachine,
|
||||
@@ -172,7 +172,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
|
||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||
this.backupManager = new RustBackupManager(logger, olmMachine, http, this.outgoingRequestProcessor);
|
||||
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
|
||||
this.logger,
|
||||
this.olmMachine,
|
||||
@@ -206,7 +206,12 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
CryptoEvent.DehydratedDeviceRotationError,
|
||||
]);
|
||||
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(
|
||||
logger,
|
||||
olmMachine,
|
||||
this.outgoingRequestProcessor,
|
||||
secretStorage,
|
||||
);
|
||||
|
||||
// Check and start in background the key backup connection
|
||||
this.checkKeyBackupAndEnable();
|
||||
@@ -1481,7 +1486,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
* @param oneTimeKeysCounts - the received one time key counts
|
||||
* @param unusedFallbackKeys - the received unused fallback keys
|
||||
* @param devices - the received device list updates
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @returns A list of processed to-device messages.
|
||||
*/
|
||||
private async receiveSyncChanges({
|
||||
events,
|
||||
@@ -1493,18 +1498,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
oneTimeKeysCounts?: Map<string, number>;
|
||||
unusedFallbackKeys?: Set<string>;
|
||||
devices?: RustSdkCryptoJs.DeviceLists;
|
||||
}): Promise<IToDeviceEvent[]> {
|
||||
const result = await logDuration(logger, "receiveSyncChanges", async () => {
|
||||
return await this.olmMachine.receiveSyncChanges(
|
||||
events ? JSON.stringify(events) : "[]",
|
||||
devices,
|
||||
oneTimeKeysCounts,
|
||||
unusedFallbackKeys,
|
||||
);
|
||||
});
|
||||
|
||||
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
|
||||
return JSON.parse(result);
|
||||
}): Promise<RustSdkCryptoJs.ProcessedToDeviceEvent[]> {
|
||||
return await this.olmMachine.receiveSyncChanges(
|
||||
events ? JSON.stringify(events) : "[]",
|
||||
devices,
|
||||
oneTimeKeysCounts,
|
||||
unusedFallbackKeys,
|
||||
);
|
||||
}
|
||||
|
||||
/** called by the sync loop to preprocess incoming to-device messages
|
||||
@@ -1512,22 +1512,56 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
*/
|
||||
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
|
||||
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]> {
|
||||
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
|
||||
// one-time-keys, or fallback keys, so just pass empty data.
|
||||
const processed = await this.receiveSyncChanges({ events });
|
||||
|
||||
// look for interesting to-device messages
|
||||
const received: ReceivedToDeviceMessage[] = [];
|
||||
|
||||
for (const message of processed) {
|
||||
if (message.type === EventType.KeyVerificationRequest) {
|
||||
const sender = message.sender;
|
||||
const transactionId = message.content.transaction_id;
|
||||
const parsedMessage: IToDeviceEvent = JSON.parse(message.rawEvent);
|
||||
|
||||
// look for interesting to-device messages
|
||||
if (parsedMessage.type === EventType.KeyVerificationRequest) {
|
||||
const sender = parsedMessage.sender;
|
||||
const transactionId = parsedMessage.content.transaction_id;
|
||||
if (transactionId && sender) {
|
||||
this.onIncomingKeyVerificationRequest(sender, transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case RustSdkCryptoJs.ProcessedToDeviceEventType.Decrypted: {
|
||||
const encryptionInfo = (message as RustSdkCryptoJs.DecryptedToDeviceEvent).encryptionInfo;
|
||||
received.push({
|
||||
message: parsedMessage,
|
||||
encryptionInfo: {
|
||||
sender: encryptionInfo.sender.toString(),
|
||||
senderDevice: encryptionInfo.senderDevice?.toString(),
|
||||
senderCurve25519KeyBase64: encryptionInfo.senderCurve25519Key,
|
||||
senderVerified: encryptionInfo.isSenderVerified(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: {
|
||||
received.push({
|
||||
message: parsedMessage,
|
||||
encryptionInfo: null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.ProcessedToDeviceEventType.UnableToDecrypt:
|
||||
// ignore messages we cannot decrypt
|
||||
break;
|
||||
case RustSdkCryptoJs.ProcessedToDeviceEventType.Invalid:
|
||||
// ignore invalid messages
|
||||
break;
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
|
||||
return received;
|
||||
}
|
||||
|
||||
/** called by the sync loop to process one time key counts and unused fallback keys
|
||||
@@ -1596,6 +1630,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
existingEncryptor.onCryptoEvent(config);
|
||||
} else {
|
||||
this.roomEncryptors[room.roomId] = new RoomEncryptor(
|
||||
this.logger.getChild(`[${room.roomId} encryption]`),
|
||||
this.olmMachine,
|
||||
this.keyClaimManager,
|
||||
this.outgoingRequestsManager,
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IHttpOpts, type MatrixHttpApi, Method } from "./http-api/index.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
|
||||
// How often we update the server capabilities.
|
||||
// 6 hours - an arbitrary value, but they should change very infrequently.
|
||||
@@ -78,7 +78,10 @@ export class ServerCapabilities {
|
||||
private retryTimeout?: ReturnType<typeof setTimeout>;
|
||||
private refreshTimeout?: ReturnType<typeof setInterval>;
|
||||
|
||||
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Starts periodically fetching the server capabilities.
|
||||
@@ -117,12 +120,12 @@ export class ServerCapabilities {
|
||||
await this.fetchCapabilities();
|
||||
this.clearTimeouts();
|
||||
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
|
||||
logger.debug("Fetched new server capabilities");
|
||||
this.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);
|
||||
this.logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+33
-57
@@ -28,7 +28,7 @@ import {
|
||||
defaultClientOpts,
|
||||
defaultSyncApiOpts,
|
||||
type SetPresence,
|
||||
mapToDeviceEvent,
|
||||
processToDeviceMessages,
|
||||
} from "./sync.ts";
|
||||
import { type MatrixEvent } from "./models/event.ts";
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type IStateEvent,
|
||||
type IStrippedState,
|
||||
type ISyncResponse,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "./sync-accumulator.ts";
|
||||
import { MatrixError } from "./http-api/index.ts";
|
||||
import {
|
||||
@@ -150,51 +151,20 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
|
||||
}
|
||||
|
||||
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
let events = data["events"] || [];
|
||||
if (events.length > 0 && this.cryptoCallbacks) {
|
||||
events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
|
||||
const events = data["events"] || [];
|
||||
let receivedToDeviceMessages: ReceivedToDeviceMessage[];
|
||||
if (this.cryptoCallbacks) {
|
||||
receivedToDeviceMessages = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
|
||||
} else {
|
||||
receivedToDeviceMessages = events.map((rawEvent) =>
|
||||
// Crypto is not enabled, so we just return the events.
|
||||
({
|
||||
message: rawEvent,
|
||||
encryptionInfo: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
events
|
||||
.map(mapToDeviceEvent)
|
||||
.map((toDeviceEvent) => {
|
||||
// map is a cheap inline forEach
|
||||
// We want to flag m.key.verification.start events as cancelled
|
||||
// if there's an accompanying m.key.verification.cancel event, so
|
||||
// we pull out the transaction IDs from the cancellation events
|
||||
// so we can flag the verification events as cancelled in the loop
|
||||
// below.
|
||||
if (toDeviceEvent.getType() === "m.key.verification.cancel") {
|
||||
const txnId: string | undefined = toDeviceEvent.getContent()["transaction_id"];
|
||||
if (txnId) {
|
||||
cancelledKeyVerificationTxns.push(txnId);
|
||||
}
|
||||
}
|
||||
|
||||
// as mentioned above, .map is a cheap inline forEach, so return
|
||||
// the unmodified event.
|
||||
return toDeviceEvent;
|
||||
})
|
||||
.forEach((toDeviceEvent) => {
|
||||
const content = toDeviceEvent.getContent();
|
||||
if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
|
||||
// the mapper already logged a warning.
|
||||
logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
toDeviceEvent.getType() === "m.key.verification.start" ||
|
||||
toDeviceEvent.getType() === "m.key.verification.request"
|
||||
) {
|
||||
const txnId = content["transaction_id"];
|
||||
if (cancelledKeyVerificationTxns.includes(txnId)) {
|
||||
toDeviceEvent.flagCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
|
||||
});
|
||||
processToDeviceMessages(receivedToDeviceMessages, this.client);
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
}
|
||||
@@ -356,8 +326,8 @@ export class SlidingSyncSdk {
|
||||
public constructor(
|
||||
private readonly slidingSync: SlidingSync,
|
||||
private readonly client: MatrixClient,
|
||||
opts?: IStoredClientOpts,
|
||||
syncOpts?: SyncApiOptions,
|
||||
opts: IStoredClientOpts | undefined,
|
||||
syncOpts: SyncApiOptions,
|
||||
) {
|
||||
this.opts = defaultClientOpts(opts);
|
||||
this.syncOpts = defaultSyncApiOpts(syncOpts);
|
||||
@@ -386,7 +356,11 @@ export class SlidingSyncSdk {
|
||||
let room = this.client.store.getRoom(roomId);
|
||||
if (!room) {
|
||||
if (!roomData.initial) {
|
||||
logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData);
|
||||
this.syncOpts.logger.debug(
|
||||
"initial flag not set but no stored room exists for room ",
|
||||
roomId,
|
||||
roomData,
|
||||
);
|
||||
return;
|
||||
}
|
||||
room = _createAndReEmitRoom(this.client, roomId, this.opts);
|
||||
@@ -396,7 +370,7 @@ export class SlidingSyncSdk {
|
||||
|
||||
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void {
|
||||
if (err) {
|
||||
logger.debug("onLifecycle", state, err);
|
||||
this.syncOpts.logger.debug("onLifecycle", state, err);
|
||||
}
|
||||
switch (state) {
|
||||
case SlidingSyncState.Complete:
|
||||
@@ -437,7 +411,9 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
} else {
|
||||
this.failCount = 0;
|
||||
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
|
||||
this.syncOpts.logger.debug(
|
||||
`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -556,7 +532,7 @@ export class SlidingSyncSdk {
|
||||
private shouldAbortSync(error: MatrixError): boolean {
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// The logout already happened, we just need to stop.
|
||||
logger.warn("Token no longer valid - assuming logout");
|
||||
this.syncOpts.logger.warn("Token no longer valid - assuming logout");
|
||||
this.stop();
|
||||
this.updateSyncState(SyncState.Error, { error });
|
||||
return true;
|
||||
@@ -684,7 +660,7 @@ export class SlidingSyncSdk {
|
||||
for (let i = timelineEvents.length - 1; i >= 0; i--) {
|
||||
const eventId = timelineEvents[i].getId();
|
||||
if (room.getTimelineForEvent(eventId)) {
|
||||
logger.debug("Already have event " + eventId + " in limited " +
|
||||
this.syncOpts.logger.debug("Already have event " + eventId + " in limited " +
|
||||
"sync - not resetting");
|
||||
limited = false;
|
||||
|
||||
@@ -893,19 +869,19 @@ export class SlidingSyncSdk {
|
||||
* Main entry point. Blocks until stop() is called.
|
||||
*/
|
||||
public async sync(): Promise<void> {
|
||||
logger.debug("Sliding sync init loop");
|
||||
this.syncOpts.logger.debug("Sliding sync init loop");
|
||||
|
||||
// 1) We need to get push rules so we can check if events should bing as we get
|
||||
// them from /sync.
|
||||
while (!this.client.isGuest()) {
|
||||
try {
|
||||
logger.debug("Getting push rules...");
|
||||
this.syncOpts.logger.debug("Getting push rules...");
|
||||
const result = await this.client.getPushRules();
|
||||
logger.debug("Got push rules");
|
||||
this.syncOpts.logger.debug("Got push rules");
|
||||
this.client.pushRules = result;
|
||||
break;
|
||||
} catch (err) {
|
||||
logger.error("Getting push rules failed", err);
|
||||
this.syncOpts.logger.error("Getting push rules failed", err);
|
||||
if (this.shouldAbortSync(<MatrixError>err)) {
|
||||
return;
|
||||
}
|
||||
@@ -920,7 +896,7 @@ export class SlidingSyncSdk {
|
||||
* Stops the sync object from syncing.
|
||||
*/
|
||||
public stop(): void {
|
||||
logger.debug("SyncApi.stop");
|
||||
this.syncOpts.logger.debug("SyncApi.stop");
|
||||
this.slidingSync.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type IRoomSummary } from "./models/room-summary.ts";
|
||||
import { type EventType } from "./@types/event.ts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
|
||||
import { ReceiptAccumulator } from "./receipt-accumulator.ts";
|
||||
import { type OlmEncryptionInfo } from "./crypto-api/index.ts";
|
||||
|
||||
interface IOpts {
|
||||
/**
|
||||
@@ -134,12 +135,31 @@ interface IAccountData {
|
||||
events: IMinimalEvent[];
|
||||
}
|
||||
|
||||
/** A to-device message as received from the sync. */
|
||||
export interface IToDeviceEvent {
|
||||
content: IContent;
|
||||
sender: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A (possibly decrypted) to-device message after it has been successfully processed by the sdk.
|
||||
*
|
||||
* If the message was encrypted, the `encryptionInfo` field will contain the encryption information.
|
||||
* If the message was sent in clear, this field will be null.
|
||||
*
|
||||
* The `message` field contains the message `type`, `content`, and `sender` as if the message was sent in clear.
|
||||
*/
|
||||
export interface ReceivedToDeviceMessage {
|
||||
/** The message type, content, and sender as if the message was sent in clear. */
|
||||
message: IToDeviceEvent;
|
||||
/**
|
||||
* Information about the encryption of the message.
|
||||
* Will be null if the message was sent in clear
|
||||
*/
|
||||
encryptionInfo: OlmEncryptionInfo | null;
|
||||
}
|
||||
|
||||
interface IToDevice {
|
||||
events: IToDeviceEvent[];
|
||||
}
|
||||
|
||||
+123
-100
@@ -31,7 +31,7 @@ import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
|
||||
import { deepCopy, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
|
||||
import { Filter } from "./filter.ts";
|
||||
import { EventTimeline } from "./models/event-timeline.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
import {
|
||||
ClientEvent,
|
||||
type IStoredClientOpts,
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
type IInvitedRoom,
|
||||
type IInviteState,
|
||||
type IJoinedRoom,
|
||||
type ILeftRoom,
|
||||
type IKnockedRoom,
|
||||
type ILeftRoom,
|
||||
type IMinimalEvent,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
@@ -53,13 +53,14 @@ import {
|
||||
type ISyncResponse,
|
||||
type ITimeline,
|
||||
type IToDeviceEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "./sync-accumulator.ts";
|
||||
import { MatrixEvent, type IEvent } from "./models/event.ts";
|
||||
import { MatrixEvent } from "./models/event.ts";
|
||||
import { type MatrixError, Method } from "./http-api/index.ts";
|
||||
import { type ISavedSync } from "./store/index.ts";
|
||||
import { EventType } from "./@types/event.ts";
|
||||
import { type IPushRules } from "./@types/PushRules.ts";
|
||||
import { RoomStateEvent, type IMarkerFoundOptions } from "./models/room-state.ts";
|
||||
import { type IMarkerFoundOptions, RoomStateEvent } from "./models/room-state.ts";
|
||||
import { RoomMemberEvent } from "./models/room-member.ts";
|
||||
import { BeaconEvent } from "./models/beacon.ts";
|
||||
import { type IEventsResponse } from "./@types/requests.ts";
|
||||
@@ -67,8 +68,6 @@ import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
|
||||
import { Feature, ServerSupport } from "./feature.ts";
|
||||
import { KnownMembership } from "./@types/membership.ts";
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
// /sync requests allow you to set a timeout= but the request may continue
|
||||
// beyond that and wedge forever, so we need to track how long we are willing
|
||||
// to keep open the connection. This constant is *ADDED* to the timeout= value
|
||||
@@ -111,12 +110,6 @@ function getFilterName(userId: string, suffix?: string): string {
|
||||
return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : "");
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
function debuglog(...params: any[]): void {
|
||||
if (!DEBUG) return;
|
||||
logger.log(...params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed into the constructor of SyncApi by MatrixClient
|
||||
*/
|
||||
@@ -133,6 +126,9 @@ export interface SyncApiOptions {
|
||||
* there are other references to the timelines for this room.
|
||||
*/
|
||||
canResetEntireTimeline?: ResetTimelineCallback;
|
||||
|
||||
/** Logger instance to use for writing debug logs. */
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface ISyncOptions {
|
||||
@@ -201,7 +197,7 @@ export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
|
||||
export function defaultSyncApiOpts(syncOpts: SyncApiOptions): SyncApiOptions {
|
||||
return {
|
||||
canResetEntireTimeline: (_roomId): boolean => false,
|
||||
...syncOpts,
|
||||
@@ -235,8 +231,8 @@ export class SyncApi {
|
||||
*/
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
opts?: IStoredClientOpts,
|
||||
syncOpts?: SyncApiOptions,
|
||||
opts: IStoredClientOpts | undefined,
|
||||
syncOpts: SyncApiOptions,
|
||||
) {
|
||||
this.opts = defaultClientOpts(opts);
|
||||
this.syncOpts = defaultSyncApiOpts(syncOpts);
|
||||
@@ -276,7 +272,7 @@ export class SyncApi {
|
||||
// 2. If it's from the first state we're seeing after joining the room
|
||||
// 3. Or whether it's coming from `syncFromCache`
|
||||
if (timelineWasEmpty) {
|
||||
logger.debug(
|
||||
this.syncOpts.logger.debug(
|
||||
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` +
|
||||
`because the timeline was empty before the marker arrived which means there is nothing to refresh.`,
|
||||
);
|
||||
@@ -308,14 +304,14 @@ export class SyncApi {
|
||||
if (isValidMsc2716Event) {
|
||||
// Saw new marker event, let's let the clients know they should
|
||||
// refresh the timeline.
|
||||
logger.debug(
|
||||
this.syncOpts.logger.debug(
|
||||
`MarkerState: Timeline needs to be refreshed because ` +
|
||||
`a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`,
|
||||
);
|
||||
room.setTimelineNeedsRefresh(true);
|
||||
room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room);
|
||||
} else {
|
||||
logger.debug(
|
||||
this.syncOpts.logger.debug(
|
||||
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
|
||||
`MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` +
|
||||
`by the room creator.`,
|
||||
@@ -488,7 +484,7 @@ export class SyncApi {
|
||||
*/
|
||||
private peekPoll(peekRoom: Room, token?: string): void {
|
||||
if (this._peekRoom !== peekRoom) {
|
||||
debuglog("Stopped peeking in room %s", peekRoom.roomId);
|
||||
this.syncOpts.logger.debug("Stopped peeking in room %s", peekRoom.roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -511,7 +507,7 @@ export class SyncApi {
|
||||
.then(
|
||||
async (res) => {
|
||||
if (this._peekRoom !== peekRoom) {
|
||||
debuglog("Stopped peeking in room %s", peekRoom.roomId);
|
||||
this.syncOpts.logger.debug("Stopped peeking in room %s", peekRoom.roomId);
|
||||
return;
|
||||
}
|
||||
// We have a problem that we get presence both from /events and /sync
|
||||
@@ -553,7 +549,7 @@ export class SyncApi {
|
||||
this.peekPoll(peekRoom, res.end);
|
||||
},
|
||||
(err) => {
|
||||
logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
|
||||
this.syncOpts.logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
|
||||
setTimeout(() => {
|
||||
this.peekPoll(peekRoom, token);
|
||||
}, 30 * 1000);
|
||||
@@ -594,7 +590,7 @@ export class SyncApi {
|
||||
private shouldAbortSync(error: MatrixError): boolean {
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// The logout already happened, we just need to stop.
|
||||
logger.warn("Token no longer valid - assuming logout");
|
||||
this.syncOpts.logger.warn("Token no longer valid - assuming logout");
|
||||
this.stop();
|
||||
this.updateSyncState(SyncState.Error, { error });
|
||||
return true;
|
||||
@@ -604,17 +600,17 @@ export class SyncApi {
|
||||
|
||||
private getPushRules = async (): Promise<void> => {
|
||||
try {
|
||||
debuglog("Getting push rules...");
|
||||
this.syncOpts.logger.debug("Getting push rules...");
|
||||
const result = await this.client.getPushRules();
|
||||
debuglog("Got push rules");
|
||||
this.syncOpts.logger.debug("Got push rules");
|
||||
|
||||
this.client.pushRules = result;
|
||||
} catch (err) {
|
||||
logger.error("Getting push rules failed", err);
|
||||
this.syncOpts.logger.error("Getting push rules failed", err);
|
||||
if (this.shouldAbortSync(<MatrixError>err)) return;
|
||||
// wait for saved sync to complete before doing anything else,
|
||||
// otherwise the sync state will end up being incorrect
|
||||
debuglog("Waiting for saved sync before retrying push rules...");
|
||||
this.syncOpts.logger.debug("Waiting for saved sync before retrying push rules...");
|
||||
await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
|
||||
return this.getPushRules(); // try again
|
||||
}
|
||||
@@ -629,12 +625,12 @@ export class SyncApi {
|
||||
};
|
||||
|
||||
private prepareLazyLoadingForSync = async (): Promise<void> => {
|
||||
debuglog("Prepare lazy loading for sync...");
|
||||
this.syncOpts.logger.debug("Prepare lazy loading for sync...");
|
||||
if (this.client.isGuest()) {
|
||||
this.opts.lazyLoadMembers = false;
|
||||
}
|
||||
if (this.opts.lazyLoadMembers) {
|
||||
debuglog("Enabling lazy load on sync filter...");
|
||||
this.syncOpts.logger.debug("Enabling lazy load on sync filter...");
|
||||
if (!this.opts.filter) {
|
||||
this.opts.filter = this.buildDefaultFilter();
|
||||
}
|
||||
@@ -644,11 +640,11 @@ export class SyncApi {
|
||||
|
||||
private storeClientOptions = async (): Promise<void> => {
|
||||
try {
|
||||
debuglog("Storing client options...");
|
||||
this.syncOpts.logger.debug("Storing client options...");
|
||||
await this.client.storeClientOptions();
|
||||
debuglog("Stored client options");
|
||||
this.syncOpts.logger.debug("Stored client options");
|
||||
} catch (err) {
|
||||
logger.error("Storing client options failed", err);
|
||||
this.syncOpts.logger.error("Storing client options failed", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -657,7 +653,7 @@ export class SyncApi {
|
||||
filterId?: string;
|
||||
filter?: Filter;
|
||||
}> => {
|
||||
debuglog("Getting filter...");
|
||||
this.syncOpts.logger.debug("Getting filter...");
|
||||
let filter: Filter;
|
||||
if (this.opts.filter) {
|
||||
filter = this.opts.filter;
|
||||
@@ -669,11 +665,11 @@ export class SyncApi {
|
||||
try {
|
||||
filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId!), filter);
|
||||
} catch (err) {
|
||||
logger.error("Getting filter failed", err);
|
||||
this.syncOpts.logger.error("Getting filter failed", err);
|
||||
if (this.shouldAbortSync(<MatrixError>err)) return {};
|
||||
// wait for saved sync to complete before doing anything else,
|
||||
// otherwise the sync state will end up being incorrect
|
||||
debuglog("Waiting for saved sync before retrying filter...");
|
||||
this.syncOpts.logger.debug("Waiting for saved sync before retrying filter...");
|
||||
await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
|
||||
return this.getFilter(); // try again
|
||||
}
|
||||
@@ -699,22 +695,22 @@ export class SyncApi {
|
||||
// Pull the saved sync token out first, before the worker starts sending
|
||||
// all the sync data which could take a while. This will let us send our
|
||||
// first incremental sync request before we've processed our saved data.
|
||||
debuglog("Getting saved sync token...");
|
||||
this.syncOpts.logger.debug("Getting saved sync token...");
|
||||
const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then((tok) => {
|
||||
debuglog("Got saved sync token");
|
||||
this.syncOpts.logger.debug("Got saved sync token");
|
||||
return tok;
|
||||
});
|
||||
|
||||
this.savedSyncPromise = this.client.store
|
||||
.getSavedSync()
|
||||
.then((savedSync) => {
|
||||
debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
|
||||
this.syncOpts.logger.debug(`Got reply from saved sync, exists? ${!!savedSync}`);
|
||||
if (savedSync) {
|
||||
return this.syncFromCache(savedSync);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Getting saved sync failed", err);
|
||||
this.syncOpts.logger.error("Getting saved sync failed", err);
|
||||
});
|
||||
|
||||
// We need to do one-off checks before we can begin the /sync loop.
|
||||
@@ -746,9 +742,9 @@ export class SyncApi {
|
||||
const savedSyncToken = await savedSyncTokenPromise;
|
||||
|
||||
if (savedSyncToken) {
|
||||
debuglog("Sending first sync request...");
|
||||
this.syncOpts.logger.debug("Sending first sync request...");
|
||||
} else {
|
||||
debuglog("Sending initial sync request...");
|
||||
this.syncOpts.logger.debug("Sending initial sync request...");
|
||||
const initialFilter = this.buildDefaultFilter();
|
||||
initialFilter.setDefinition(filter.getDefinition());
|
||||
initialFilter.setTimelineLimit(this.opts.initialSyncLimit!);
|
||||
@@ -762,7 +758,7 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
// Now wait for the saved sync to finish...
|
||||
debuglog("Waiting for saved sync before starting sync processing...");
|
||||
this.syncOpts.logger.debug("Waiting for saved sync before starting sync processing...");
|
||||
await this.savedSyncPromise;
|
||||
// process the first sync request and continue syncing with the normal filterId
|
||||
return this.doSync({ filter: filterId });
|
||||
@@ -772,7 +768,7 @@ export class SyncApi {
|
||||
* Stops the sync object from syncing.
|
||||
*/
|
||||
public stop(): void {
|
||||
debuglog("SyncApi.stop");
|
||||
this.syncOpts.logger.debug("SyncApi.stop");
|
||||
// It is necessary to check for the existance of
|
||||
// globalThis.window AND globalThis.window.removeEventListener.
|
||||
// Some platforms (e.g. React Native) register globalThis.window,
|
||||
@@ -804,7 +800,7 @@ export class SyncApi {
|
||||
* should have been acquired via client.store.getSavedSync().
|
||||
*/
|
||||
private async syncFromCache(savedSync: ISavedSync): Promise<void> {
|
||||
debuglog("sync(): not doing HTTP hit, instead returning stored /sync data");
|
||||
this.syncOpts.logger.debug("sync(): not doing HTTP hit, instead returning stored /sync data");
|
||||
|
||||
const nextSyncToken = savedSync.nextBatch;
|
||||
|
||||
@@ -829,7 +825,7 @@ export class SyncApi {
|
||||
try {
|
||||
await this.processSyncResponse(syncEventData, data);
|
||||
} catch (e) {
|
||||
logger.error("Error processing cached sync", e);
|
||||
this.syncOpts.logger.error("Error processing cached sync", e);
|
||||
}
|
||||
|
||||
// Don't emit a prepared if we've bailed because the store is invalid:
|
||||
@@ -880,7 +876,7 @@ export class SyncApi {
|
||||
} catch (e) {
|
||||
// log the exception with stack if we have it, else fall back
|
||||
// to the plain description
|
||||
logger.error("Caught /sync error", e);
|
||||
this.syncOpts.logger.error("Caught /sync error", e);
|
||||
|
||||
// Emit the exception for client handling
|
||||
this.client.emit(ClientEvent.SyncUnexpectedError, <Error>e);
|
||||
@@ -915,7 +911,7 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
if (!this.running) {
|
||||
debuglog("Sync no longer running: exiting.");
|
||||
this.syncOpts.logger.debug("Sync no longer running: exiting.");
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.reject();
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
@@ -998,7 +994,7 @@ export class SyncApi {
|
||||
|
||||
private async onSyncError(err: MatrixError): Promise<boolean> {
|
||||
if (!this.running) {
|
||||
debuglog("Sync no longer running: exiting");
|
||||
this.syncOpts.logger.debug("Sync no longer running: exiting");
|
||||
if (this.connectionReturnedResolvers) {
|
||||
this.connectionReturnedResolvers.reject();
|
||||
this.connectionReturnedResolvers = undefined;
|
||||
@@ -1007,16 +1003,16 @@ export class SyncApi {
|
||||
return true; // abort
|
||||
}
|
||||
|
||||
logger.error("/sync error %s", err);
|
||||
this.syncOpts.logger.error("/sync error %s", err);
|
||||
|
||||
if (this.shouldAbortSync(err)) {
|
||||
return true; // abort
|
||||
}
|
||||
|
||||
this.failedSyncCount++;
|
||||
logger.log("Number of consecutive failed sync requests:", this.failedSyncCount);
|
||||
this.syncOpts.logger.debug("Number of consecutive failed sync requests:", this.failedSyncCount);
|
||||
|
||||
debuglog("Starting keep-alive");
|
||||
this.syncOpts.logger.debug("Starting keep-alive");
|
||||
// Note that we do *not* mark the sync connection as
|
||||
// lost yet: we only do this if a keepalive poke
|
||||
// fails, since long lived HTTP connections will
|
||||
@@ -1144,53 +1140,23 @@ export class SyncApi {
|
||||
|
||||
// handle to-device events
|
||||
if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
|
||||
let toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
|
||||
const toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
|
||||
|
||||
let receivedToDeviceMessages: ReceivedToDeviceMessage[];
|
||||
if (this.syncOpts.cryptoCallbacks) {
|
||||
toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
|
||||
receivedToDeviceMessages =
|
||||
await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
|
||||
} else {
|
||||
receivedToDeviceMessages = toDeviceMessages.map((rawEvent) =>
|
||||
// Crypto is not enabled, so we just return the events.
|
||||
({
|
||||
message: rawEvent,
|
||||
encryptionInfo: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
toDeviceMessages
|
||||
.map(mapToDeviceEvent)
|
||||
.map((toDeviceEvent) => {
|
||||
// map is a cheap inline forEach
|
||||
// We want to flag m.key.verification.start events as cancelled
|
||||
// if there's an accompanying m.key.verification.cancel event, so
|
||||
// we pull out the transaction IDs from the cancellation events
|
||||
// so we can flag the verification events as cancelled in the loop
|
||||
// below.
|
||||
if (toDeviceEvent.getType() === "m.key.verification.cancel") {
|
||||
const txnId: string = toDeviceEvent.getContent()["transaction_id"];
|
||||
if (txnId) {
|
||||
cancelledKeyVerificationTxns.push(txnId);
|
||||
}
|
||||
}
|
||||
|
||||
// as mentioned above, .map is a cheap inline forEach, so return
|
||||
// the unmodified event.
|
||||
return toDeviceEvent;
|
||||
})
|
||||
.forEach(function (toDeviceEvent) {
|
||||
const content = toDeviceEvent.getContent();
|
||||
if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
|
||||
// the mapper already logged a warning.
|
||||
logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
toDeviceEvent.getType() === "m.key.verification.start" ||
|
||||
toDeviceEvent.getType() === "m.key.verification.request"
|
||||
) {
|
||||
const txnId = content["transaction_id"];
|
||||
if (cancelledKeyVerificationTxns.includes(txnId)) {
|
||||
toDeviceEvent.flagCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
|
||||
});
|
||||
processToDeviceMessages(receivedToDeviceMessages, client);
|
||||
} else {
|
||||
// no more to-device events: we can stop polling with a short timeout.
|
||||
this.catchingUp = false;
|
||||
@@ -1353,7 +1319,7 @@ export class SyncApi {
|
||||
for (let i = timelineEvents.length - 1; i >= 0; i--) {
|
||||
const eventId = timelineEvents[i].getId()!;
|
||||
if (room.getTimelineForEvent(eventId)) {
|
||||
debuglog(`Already have event ${eventId} in limited sync - not resetting`);
|
||||
this.syncOpts.logger.debug(`Already have event ${eventId} in limited sync - not resetting`);
|
||||
limited = false;
|
||||
|
||||
// we might still be missing some of the events before i;
|
||||
@@ -1413,7 +1379,7 @@ export class SyncApi {
|
||||
await this.injectRoomEvents(room, stateEvents, undefined, timelineEvents, syncEventData.fromCache);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Failed to process events on room ${room.roomId}:`, e);
|
||||
this.syncOpts.logger.error(`Failed to process events on room ${room.roomId}:`, e);
|
||||
}
|
||||
|
||||
// set summary after processing events,
|
||||
@@ -1894,7 +1860,7 @@ export class SyncApi {
|
||||
* but this might help us reconnect a little faster.
|
||||
*/
|
||||
private onOnline = (): void => {
|
||||
debuglog("Browser thinks we are back online");
|
||||
this.syncOpts.logger.debug("Browser thinks we are back online");
|
||||
this.startKeepAlives(0);
|
||||
};
|
||||
}
|
||||
@@ -1946,8 +1912,65 @@ export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts:
|
||||
return room;
|
||||
}
|
||||
|
||||
export function mapToDeviceEvent(plainOldJsObject: Partial<IEvent>): MatrixEvent {
|
||||
// to-device events should not have a `room_id` property, but let's be sure
|
||||
delete plainOldJsObject.room_id;
|
||||
return new MatrixEvent(plainOldJsObject);
|
||||
/**
|
||||
* Process a list of (decrypted, where possible) received to-device events.
|
||||
*
|
||||
* Emits the appropriate {@link ClientEvent.ReceivedToDeviceMessage} event.
|
||||
* Also converts the events into `MatrixEvent`s, and emits the now deprecated {@link ClientEvent.ToDeviceEvent} events for compatibility.
|
||||
* */
|
||||
export function processToDeviceMessages(toDeviceMessages: ReceivedToDeviceMessage[], client: MatrixClient): void {
|
||||
const cancelledKeyVerificationTxns: string[] = [];
|
||||
toDeviceMessages
|
||||
.map((processedMessage) => {
|
||||
// map is a cheap inline forEach
|
||||
// We want to flag m.key.verification.start events as cancelled
|
||||
// if there's an accompanying m.key.verification.cancel event, so
|
||||
// we pull out the transaction IDs from the cancellation events
|
||||
// so we can flag the verification events as cancelled in the loop
|
||||
// below.
|
||||
if (processedMessage.message.type === "m.key.verification.cancel") {
|
||||
const txnId: string = processedMessage.message.content["transaction_id"];
|
||||
if (txnId) {
|
||||
cancelledKeyVerificationTxns.push(txnId);
|
||||
}
|
||||
}
|
||||
|
||||
// as mentioned above, .map is a cheap inline forEach, so return
|
||||
// the unmodified event.
|
||||
return processedMessage;
|
||||
})
|
||||
.forEach(function (processedEvent) {
|
||||
// For backwards compatibility, we also emit the event as a `MatrixEvent` using `ClientEvent.ToDeviceEvent`.
|
||||
{
|
||||
const toDeviceEvent = processedEvent.message;
|
||||
const content = toDeviceEvent.content;
|
||||
// The message is cloned before being passed to the MatrixEvent constructor, because
|
||||
// the `makeEncrypted` method will mutate the type and content properties of the original message and will interfere
|
||||
// with the emitted event for `ReceivedToDeviceMessage`.
|
||||
const deprecatedCompatibilityEvent = new MatrixEvent(Object.assign({}, toDeviceEvent));
|
||||
if (
|
||||
toDeviceEvent.type === "m.key.verification.start" ||
|
||||
toDeviceEvent.type === "m.key.verification.request"
|
||||
) {
|
||||
const txnId = content["transaction_id"];
|
||||
if (cancelledKeyVerificationTxns.includes(txnId)) {
|
||||
deprecatedCompatibilityEvent.flagCancelled();
|
||||
}
|
||||
}
|
||||
if (processedEvent.encryptionInfo) {
|
||||
// Restore partially the legacy behavior to detect encrypted messages.
|
||||
// Now `event.isEncrypted()` will return true.
|
||||
deprecatedCompatibilityEvent.makeEncrypted(
|
||||
EventType.RoomMessageEncrypted,
|
||||
{ ciphertext: "" },
|
||||
processedEvent.encryptionInfo.senderCurve25519KeyBase64,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
client.emit(ClientEvent.ToDeviceEvent, deprecatedCompatibilityEvent);
|
||||
}
|
||||
|
||||
client.emit(ClientEvent.ReceivedToDeviceMessage, processedEvent);
|
||||
});
|
||||
}
|
||||
|
||||
+3
-1
@@ -435,7 +435,9 @@ export function immediate(): Promise<void> {
|
||||
export function isNullOrUndefined(val: any): boolean {
|
||||
return val === null || val === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use {@link PromiseWithResolvers} instead.
|
||||
*/
|
||||
export type IDeferred<T> = PromiseWithResolvers<T>;
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["es2024"]
|
||||
"lib": ["es2024"],
|
||||
"baseUrl": ".",
|
||||
"typeRoots": ["./node_modules/@types"]
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./spec/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user