Compare commits
34 Commits
v37.3.0-rc.0
...
v37.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a29b8736f3 | |||
| 1c8a1cd5a1 | |||
| 663136a95a | |||
| d67b19fa88 | |||
| 6ec200adcf | |||
| 19b1b901f5 | |||
| 1f52fa0c43 | |||
| b08c083c46 | |||
| 69f1bea89b | |||
| a52c64b747 | |||
| de9eb0e7f2 | |||
| a97d7f1133 | |||
| bc399abf6e | |||
| 1085a2469a | |||
| 52a9c52b12 | |||
| ac49137c0e | |||
| a179227bf1 | |||
| 64e27f5d3c | |||
| 634651859b | |||
| 480c8e86a4 | |||
| 1ba4412260 | |||
| e3a3a52f2a | |||
| 3f03c1da89 | |||
| eb793aaa08 | |||
| f7bab544a7 | |||
| b3fd92ad16 | |||
| ba71235539 | |||
| d6ede767c9 | |||
| 43f388d89a | |||
| 1d46b182c6 | |||
| 352f6986cd | |||
| 0ca72ec466 | |||
| 375f086f44 | |||
| 5976083e32 |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # 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@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
@@ -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@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
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@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
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@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
Changes in [37.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.5.0) (2025-05-06)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Stabilise MSC3765 ([#4767](https://github.com/matrix-org/matrix-js-sdk/pull/4767)). Contributed by @Johennes.
|
||||
* Inherit `methodFactory` extensions from the parent to the child loggers. ([#4809](https://github.com/matrix-org/matrix-js-sdk/pull/4809)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Fix token refresh behaviour for non-expired tokens ([#4827](https://github.com/matrix-org/matrix-js-sdk/pull/4827)). Contributed by @RiotRobot.
|
||||
* Refactor how token refreshing works to be more resilient ([#4819](https://github.com/matrix-org/matrix-js-sdk/pull/4819)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.4.0) (2025-04-22)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC: Add combined `toDeviceAndRoomKeyTransport` ([#4792](https://github.com/matrix-org/matrix-js-sdk/pull/4792)). Contributed by @toger5.
|
||||
* Make logging consistent for matrixRTC ([#4788](https://github.com/matrix-org/matrix-js-sdk/pull/4788)). Contributed by @toger5.
|
||||
* MatrixRTC: ToDevice distribution for media stream keys ([#4785](https://github.com/matrix-org/matrix-js-sdk/pull/4785)). Contributed by @BillCarsonFr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix token refresh racing with other requests and not using new token ([#4798](https://github.com/matrix-org/matrix-js-sdk/pull/4798)). Contributed by @t3chguy.
|
||||
* Fix fallback to MemoryCryptoStore when LocalStorage unavailable ([#4797](https://github.com/matrix-org/matrix-js-sdk/pull/4797)). Contributed by @t3chguy.
|
||||
* Remove duplicate `deleteSecretStorage` in `RustCrypto.resetEncryption` ([#4789](https://github.com/matrix-org/matrix-js-sdk/pull/4789)). Contributed by @florianduros.
|
||||
* Fix `RustCrypto.resetEncryption` failure ([#4772](https://github.com/matrix-org/matrix-js-sdk/pull/4772)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [37.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.3.0) (2025-04-08)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status ([#4747](https://github.com/matrix-org/matrix-js-sdk/pull/4747)). Contributed by @toger5.
|
||||
* Abstract logout-causing error type from tokenRefreshFunction calls ([#4765](https://github.com/matrix-org/matrix-js-sdk/pull/4765)). Contributed by @t3chguy.
|
||||
* Improve PushProcessor::getPushRuleGlobRegex ([#4764](https://github.com/matrix-org/matrix-js-sdk/pull/4764)). Contributed by @t3chguy.
|
||||
* Export push processor \& method for converting matrix glob to regexp ([#4763](https://github.com/matrix-org/matrix-js-sdk/pull/4763)). Contributed by @t3chguy.
|
||||
* Add authenticated media parameter to getMediaConfig ([#4762](https://github.com/matrix-org/matrix-js-sdk/pull/4762)). Contributed by @m004.
|
||||
* Rust crypto: set a timeout on outgoing HTTP requests ([#4761](https://github.com/matrix-org/matrix-js-sdk/pull/4761)). Contributed by @richvdh.
|
||||
* Switch sliding sync support to simplified sliding sync ([#4400](https://github.com/matrix-org/matrix-js-sdk/pull/4400)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.3.0-rc.0",
|
||||
"version": "37.5.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -56,7 +56,7 @@
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
|
||||
@@ -196,14 +196,14 @@ describe("Topic content helpers", () => {
|
||||
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
|
||||
topic: "pizza",
|
||||
[M_TOPIC.name]: [
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
{
|
||||
body: "<b>pizza</b>",
|
||||
mimetype: "text/html",
|
||||
},
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
it("sends encrypted (encryptAndSendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
const payload = { hello: "world" };
|
||||
const embeddedClient = client as RoomWidgetClient;
|
||||
await embeddedClient.encryptAndSendToDevices(
|
||||
await embeddedClient.encryptAndSendToDevice(
|
||||
"org.example.foo",
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
||||
|
||||
@@ -29,13 +29,18 @@ import {
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, type QueryDict } from "../../../src/utils";
|
||||
import { defer, type QueryDict, sleep } from "../../../src/utils";
|
||||
import { type Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
const idBaseUrl = "http://idBaseUrl";
|
||||
const prefix = ClientPrefix.V3;
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should support aborting multiple times", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
@@ -351,7 +356,9 @@ describe("FetchHttpApi", () => {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
|
||||
headers: {},
|
||||
});
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
@@ -361,12 +368,22 @@ describe("FetchHttpApi", () => {
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
it("should not try to refresh the token if it has plenty of time left before expiry", async () => {
|
||||
// We can't specify an expiry for the initial token, so this should:
|
||||
// * Try once, fail
|
||||
// * Attempt a refresh, get a token that's not expired
|
||||
// * Try again, still fail
|
||||
// * Not refresh the token because it's not expired
|
||||
// ...which is TWO attempts and ONE refresh (which doesn't really
|
||||
// count because it's only to get a token with an expiry)
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
// This needs to be sufficiently high that it's over the threshold for
|
||||
// 'plenty of time' (which is a minute in practice).
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
@@ -386,7 +403,7 @@ describe("FetchHttpApi", () => {
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
// tried to refresh the token once (to get the one with an expiry)
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -397,6 +414,54 @@ describe("FetchHttpApi", () => {
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should try to refresh the token if it will expire soon", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
|
||||
// first refresh is to get a token with an expiry at all, because we
|
||||
// can't specify an expiry on the token we inject
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// next refresh is to return a token that will expire 'soon'
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// ...and finally we return a token that has adequate time left
|
||||
// so that it will cease retrying and fail the request.
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// We should have seen the 3 token refreshes, as above.
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -492,8 +557,6 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not make multiple concurrent refresh token requests", async () => {
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -523,7 +586,7 @@ describe("FetchHttpApi", () => {
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
|
||||
await sleep(0); // wait for requests to fire
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -547,4 +610,66 @@ describe("FetchHttpApi", () => {
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
|
||||
it("should use newly refreshed token if request starts mid-refresh", async () => {
|
||||
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
async text() {
|
||||
return JSON.stringify(tokenInactiveError.data);
|
||||
},
|
||||
async json() {
|
||||
return tokenInactiveError.data;
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
|
||||
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
doNotAttemptTokenRefresh: false,
|
||||
tokenRefreshFunction,
|
||||
accessToken: "ACCESS_TOKEN",
|
||||
refreshToken: "REFRESH_TOKEN",
|
||||
});
|
||||
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return "{}";
|
||||
},
|
||||
async json() {
|
||||
return {};
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
|
||||
await prom1;
|
||||
await prom2;
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // 2 original calls + 1 retry
|
||||
expect(fetchFn.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(fetchFn.mock.calls[2][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer NEW_ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../sr
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
@@ -34,6 +34,8 @@ describe("MatrixRTCSession", () => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
|
||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||
client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" });
|
||||
client.decryptEventIfNeeded = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -478,19 +480,23 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
describe("key management", () => {
|
||||
// TODO make this test suit only test the encryption manager. And mock the transport directly not the session.
|
||||
describe("sending", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendDelayedStateMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
let sendToDeviceMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendDelayedStateMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
sendToDeviceMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
client.encryptAndSendToDevice = sendToDeviceMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
@@ -531,12 +537,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -584,7 +585,7 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
jest.advanceTimersByTime(10000);
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
@@ -739,12 +740,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -793,12 +789,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -831,12 +822,7 @@ describe("MatrixRTCSession", () => {
|
||||
{
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, expect.stringMatching(".*"))],
|
||||
sent_ts: Date.now(),
|
||||
},
|
||||
);
|
||||
@@ -849,6 +835,7 @@ describe("MatrixRTCSession", () => {
|
||||
it("rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const KEY_DELAY = 3000;
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
@@ -869,7 +856,8 @@ describe("MatrixRTCSession", () => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
|
||||
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
expect(firstKeysPayload.keys[0].index).toEqual(0);
|
||||
@@ -886,14 +874,24 @@ describe("MatrixRTCSession", () => {
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
jest.advanceTimersByTime(KEY_DELAY);
|
||||
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);
|
||||
// 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);
|
||||
jest.advanceTimersByTime(7000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
|
||||
expect(secondKeysPayload.keys).toHaveLength(1);
|
||||
expect(secondKeysPayload.keys[0].index).toEqual(1);
|
||||
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2);
|
||||
// initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();`
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -982,64 +980,75 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("send key as to device", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const keySentPromise = new Promise((resolve) => {
|
||||
sendToDeviceMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, {
|
||||
manageMediaKeys: true,
|
||||
useNewMembershipManager: true,
|
||||
useExperimentalToDeviceTransport: true,
|
||||
});
|
||||
|
||||
await keySentPromise;
|
||||
|
||||
expect(sendToDeviceMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("receiving", () => {
|
||||
it("collects keys from encryption events", () => {
|
||||
it("collects keys from encryption events", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys at non-zero indices", () => {
|
||||
it("collects keys at non-zero indices", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1049,61 +1058,48 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
});
|
||||
|
||||
it("collects keys by merging", () => {
|
||||
it("collects keys by merging", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1);
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
encryptionKeyChangedListener.mockClear();
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(3);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("this is the key"),
|
||||
4,
|
||||
@@ -1113,93 +1109,102 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
});
|
||||
|
||||
it("ignores older keys at same index", () => {
|
||||
it("ignores older keys at same index", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("newer key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(2000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("older key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))],
|
||||
},
|
||||
2000,
|
||||
),
|
||||
);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("newer key"),
|
||||
0,
|
||||
"@bob:example.org:bobsphone",
|
||||
);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2);
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(3);
|
||||
});
|
||||
|
||||
it("key timestamps are treated as monotonic", () => {
|
||||
it("key timestamps are treated as monotonic", async () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("first key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: encodeBase64(Buffer.from("second key", "utf-8")),
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key
|
||||
} as unknown as MatrixEvent);
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, encodeBase64(Buffer.from("second key", "utf-8")))],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledWith(
|
||||
textEncoder.encode("second key"),
|
||||
0,
|
||||
@@ -1210,31 +1215,25 @@ describe("MatrixRTCSession", () => {
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", {
|
||||
device_id: client.getDeviceId(),
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue(client.getUserId()),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
|
||||
const encryptionKeyChangedListener = jest.fn();
|
||||
sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener);
|
||||
sess!.reemitEncryptionKeys();
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0);
|
||||
expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0);
|
||||
});
|
||||
|
||||
it("tracks total age statistics for collected keys", () => {
|
||||
it("tracks total age statistics for collected keys", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
@@ -1242,59 +1241,49 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
// defaults to getTs()
|
||||
jest.setSystemTime(1000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
} as unknown as MatrixEvent);
|
||||
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent(
|
||||
"io.element.call.encryption_keys",
|
||||
"@bob:example.org",
|
||||
"1234roomId",
|
||||
{
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
},
|
||||
0,
|
||||
),
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000);
|
||||
|
||||
jest.setSystemTime(2000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 0,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000);
|
||||
|
||||
jest.setSystemTime(3000);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
mockRoom.emitTimelineEvent(
|
||||
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")],
|
||||
sent_ts: 1000,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
} as unknown as MatrixEvent);
|
||||
);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
|
||||
@@ -16,15 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mock } from "jest-mock";
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
type IRoomTimelineData,
|
||||
MatrixClient,
|
||||
type MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
@@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembership
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -763,3 +764,19 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom(membershipTemplate);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
membershipManager.join([]);
|
||||
membershipManager.join([]);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const logline: string = spy.mock.calls[0][0];
|
||||
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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 { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { 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";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
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 { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
|
||||
import type { Mocked } from "jest-mock";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
};
|
||||
let transport: RoomKeyTransport;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
onCallEncryptionMock.mockReset();
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
transport = new RoomKeyTransport(room, client, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
|
||||
onCallEncryptionMock(...p);
|
||||
});
|
||||
transport.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
transport.stop();
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
});
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
jest.useFakeTimers();
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
|
||||
);
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
|
||||
);
|
||||
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
// empty content
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {},
|
||||
}),
|
||||
// no sender
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Call_id not empty string
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "FOO",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Various Malformed keys
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: "FOO",
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [{ index: 0 }],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
key: "BASE64KEY",
|
||||
index: "mcall",
|
||||
},
|
||||
],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.onEncryptionEvent(event);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
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 { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { defer } from "../../../src/utils.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("ToDeviceKeyTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: ToDeviceKeyTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
});
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
transport.start();
|
||||
|
||||
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",
|
||||
),
|
||||
]);
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
"io.element.call.encryption_keys",
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
|
||||
],
|
||||
{
|
||||
keys: {
|
||||
index: keyIndex,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit when a key is received", async () => {
|
||||
const deferred = defer<{ userId: string; deviceId: string; keyBase64Encoded: string; index: number }>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
deferred.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
});
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { userId, deviceId, keyBase64Encoded, index } = await deferred.promise;
|
||||
expect(userId).toBe("@bob:example.org");
|
||||
expect(deviceId).toBe("BOBDEVICE");
|
||||
expect(keyBase64Encoded).toBe(testEncoded);
|
||||
expect(index).toBe(testKeyIndex);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
|
||||
});
|
||||
|
||||
it("should not sent to ourself", async () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
mockCallMembership(
|
||||
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
|
||||
roomId,
|
||||
"@alice:example.org",
|
||||
),
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should warn when there is a room mismatch", () => {
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: "!anotherroom:id",
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
{
|
||||
keys: {},
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { index: 0 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF" },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: {},
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!wrong_room",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.start();
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ It is very specific to the MembershipManager.spec.ts file and introduces the fol
|
||||
import { TestEnvironment } from "jest-environment-jsdom";
|
||||
|
||||
import { logger as rootLogger } from "../../../src/logger";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
const logger = rootLogger.getChild("[MatrixRTCSession]");
|
||||
|
||||
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||
handleTestEvent(event: any) {
|
||||
|
||||
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
|
||||
import { EventEmitter } from "stream";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
|
||||
@@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData,
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const room = {
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
return room;
|
||||
}) as unknown as Room;
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
@@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue(membershipData),
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getSender: jest.fn().mockReturnValue(sender),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
return {
|
||||
key: key,
|
||||
index: id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,10 +130,12 @@ describe("OidcTokenRefresher", () => {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
@@ -144,10 +146,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
@@ -189,10 +193,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
expect(result1).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
@@ -200,10 +206,12 @@ describe("OidcTokenRefresher", () => {
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(third).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
@@ -261,10 +269,12 @@ describe("OidcTokenRefresher", () => {
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TokenRefreshLogoutError when expired", async () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys as JSON 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
@@ -514,11 +514,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should import and export keys as JSON", async () => {
|
||||
@@ -539,11 +536,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2255,10 +2249,10 @@ describe("RustCrypto", () => {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
beforeEach(() => {
|
||||
secretStorage = {
|
||||
setDefaultKeyId: jest.fn(),
|
||||
setDefaultKeyId: jest.fn().mockResolvedValue(undefined),
|
||||
hasKey: jest.fn().mockResolvedValue(false),
|
||||
getKey: jest.fn().mockResolvedValue(null),
|
||||
store: jest.fn(),
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("defaultKeyId"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
|
||||
+3
-6
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { UnstableValue } from "../NamespacedValue.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
import { type IMessageRendering } from "./extensible_events.ts";
|
||||
|
||||
/**
|
||||
@@ -42,20 +42,17 @@ import { type IMessageRendering } from "./extensible_events.ts";
|
||||
/**
|
||||
* The event type for an m.topic event (in content)
|
||||
*/
|
||||
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
|
||||
export const M_TOPIC = new NamespacedValue("m.topic");
|
||||
|
||||
/**
|
||||
* The event content for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicContent = IMessageRendering[];
|
||||
|
||||
type MTopicStable = { [M_TOPIC.altName]: MTopicContent };
|
||||
type MTopicUnstable = { [M_TOPIC.name]: MTopicContent };
|
||||
|
||||
/**
|
||||
* The event definition for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicEvent = (MTopicStable & MTopicUnstable) | MTopicStable | MTopicUnstable;
|
||||
export type MTopicEvent = { "m.topic": MTopicContent };
|
||||
|
||||
/**
|
||||
* The event content for an m.room.topic event
|
||||
|
||||
+24
-1
@@ -207,7 +207,7 @@ import {
|
||||
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
|
||||
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer.ts";
|
||||
import { type UIARequest } from "./@types/uia.ts";
|
||||
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
|
||||
@@ -7942,6 +7942,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will encrypt the payload for all devices in the list and will queue it.
|
||||
* The type of the sent to-device message will be `m.room.encrypted`.
|
||||
* @param eventType - The type of event to send
|
||||
* @param devices - The list of devices to send the event to.
|
||||
* @param payload - The payload to send. This will be encrypted.
|
||||
* @returns Promise which resolves once queued there is no error feedback when sending fails.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
|
||||
}
|
||||
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
|
||||
|
||||
// TODO The batch mechanism removes all possibility to get error feedbacks..
|
||||
// We might want instead to do the API call directly and pass the errors back.
|
||||
await this.queueToDevice(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
|
||||
@@ -189,12 +189,14 @@ export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: st
|
||||
|
||||
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
|
||||
const renderings = [];
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
// Put HTML first because clients will render the first type in
|
||||
// the array that they understand
|
||||
if (isProvided(htmlTopic)) {
|
||||
renderings.push({ body: htmlTopic, mimetype: "text/html" });
|
||||
}
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
return { topic, [M_TOPIC.name]: renderings };
|
||||
};
|
||||
|
||||
|
||||
@@ -204,9 +204,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
);
|
||||
|
||||
try {
|
||||
if (!(globalThis.localStorage instanceof Storage)) {
|
||||
throw new Error("localStorage is not available");
|
||||
}
|
||||
return new LocalStorageCryptoStore(globalThis.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
})
|
||||
|
||||
+19
-23
@@ -62,17 +62,6 @@ interface IStateEventRequest {
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface OlmDevice {
|
||||
/**
|
||||
* The user ID of the device owner.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The device ID of the device.
|
||||
*/
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
@@ -464,6 +453,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* by {@link MatrixClient.encryptAndSendToDevice}.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of devices) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
@@ -495,18 +503,6 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of userDeviceInfoArr) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
|
||||
*
|
||||
|
||||
+34
-66
@@ -18,10 +18,10 @@ limitations under the License.
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
import { checkObjectHasKeys, encodeParams } from "../utils.ts";
|
||||
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
|
||||
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { Method } from "./method.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
|
||||
import {
|
||||
HttpApiEvent,
|
||||
type HttpApiEventHandlerMap,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./interface.ts";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
|
||||
import { type QueryDict } from "../utils.ts";
|
||||
import { singleAsyncExecution } from "../utils/decorators.ts";
|
||||
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
|
||||
|
||||
interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
@@ -43,14 +43,9 @@ export type ResponseType<T, O extends IHttpOpts> = O extends { json: false }
|
||||
? T
|
||||
: TypedResponse<T>;
|
||||
|
||||
const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
export class FetchHttpApi<O extends IHttpOpts> {
|
||||
private abortController = new AbortController();
|
||||
private readonly tokenRefresher: TokenRefresher;
|
||||
|
||||
public constructor(
|
||||
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
|
||||
@@ -59,6 +54,8 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
|
||||
opts.onlyData = !!opts.onlyData;
|
||||
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
|
||||
|
||||
this.tokenRefresher = new TokenRefresher(opts);
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
@@ -113,12 +110,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
return this.requestOtherUrl(method, fullUri, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Perform an authorised request to the homeserver.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
@@ -146,37 +137,49 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* @returns Rejects with an error if a problem occurred.
|
||||
* This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public async authedRequest<T>(
|
||||
public authedRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
queryParams: QueryDict = {},
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
if (!queryParams) queryParams = {};
|
||||
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
|
||||
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
|
||||
private async doAuthedRequest<T>(
|
||||
attempt: number,
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams: QueryDict,
|
||||
body?: Body,
|
||||
paramOpts: IRequestOpts = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
// avoid mutating paramOpts so they can be used on retry
|
||||
const opts = { ...paramOpts };
|
||||
const opts = deepCopy(paramOpts);
|
||||
// we have to manually copy the abortSignal over as it is not a plain object
|
||||
opts.abortSignal = paramOpts.abortSignal;
|
||||
|
||||
if (this.opts.accessToken) {
|
||||
// Take a snapshot of the current token state before we start the request so we can reference it if we error
|
||||
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
|
||||
if (requestSnapshot.accessToken) {
|
||||
if (this.opts.useAuthorizationHeader) {
|
||||
if (!opts.headers) {
|
||||
opts.headers = {};
|
||||
}
|
||||
if (!opts.headers.Authorization) {
|
||||
opts.headers.Authorization = "Bearer " + this.opts.accessToken;
|
||||
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
|
||||
}
|
||||
if (queryParams.access_token) {
|
||||
delete queryParams.access_token;
|
||||
}
|
||||
} else if (!queryParams.access_token) {
|
||||
queryParams.access_token = this.opts.accessToken;
|
||||
queryParams.access_token = requestSnapshot.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Await any ongoing token refresh
|
||||
await this.tokenRefreshPromise;
|
||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -184,27 +187,19 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
||||
const tokenRefreshPromise = this.tryRefreshToken();
|
||||
this.tokenRefreshPromise = Promise.allSettled([tokenRefreshPromise]);
|
||||
const outcome = await tokenRefreshPromise;
|
||||
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
|
||||
if (outcome === TokenRefreshOutcome.Success) {
|
||||
// if we got a new token retry the request
|
||||
return this.authedRequest(method, path, queryParams, body, {
|
||||
...paramOpts,
|
||||
doNotAttemptTokenRefresh: true,
|
||||
});
|
||||
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
|
||||
}
|
||||
if (outcome === TokenRefreshOutcome.Failure) {
|
||||
throw new TokenRefreshError(error);
|
||||
}
|
||||
// Fall through to SessionLoggedOut handler below
|
||||
}
|
||||
|
||||
// otherwise continue with error handling
|
||||
if (error.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
if (!opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
|
||||
}
|
||||
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
|
||||
}
|
||||
@@ -213,33 +208,6 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
@singleAsyncExecution
|
||||
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request to the homeserver without any credentials.
|
||||
* @param method - The HTTP method e.g. "GET".
|
||||
|
||||
@@ -24,9 +24,20 @@ export type Body = Record<string, any> | BodyInit;
|
||||
* Unencrypted access and (optional) refresh token
|
||||
*/
|
||||
export type AccessTokens = {
|
||||
/**
|
||||
* The new access token to use for authenticated requests
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The new refresh token to use for refreshing tokens, optional
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Approximate date when the access token will expire, optional
|
||||
*/
|
||||
expiry?: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Function that performs token refresh using the given refreshToken.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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 { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
|
||||
import { type IHttpOpts } from "./interface.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* This is an internal module. See {@link MatrixHttpApi} for the public class.
|
||||
*/
|
||||
|
||||
export const enum TokenRefreshOutcome {
|
||||
Success = "success",
|
||||
Failure = "failure",
|
||||
Logout = "logout",
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}
|
||||
|
||||
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
|
||||
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
|
||||
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
|
||||
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
|
||||
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
|
||||
|
||||
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the access token and refresh token for authenticated requests.
|
||||
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
|
||||
*/
|
||||
export class TokenRefresher {
|
||||
public constructor(private readonly opts: Opts) {}
|
||||
|
||||
/**
|
||||
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
|
||||
* @private
|
||||
*/
|
||||
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
|
||||
|
||||
private latestTokenRefreshExpiry?: Date;
|
||||
|
||||
/**
|
||||
* This function is called before every request to ensure that the access token is valid.
|
||||
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
|
||||
* handler if an M_UNKNOWN_TOKEN error is encountered.
|
||||
*/
|
||||
public async prepareForRequest(): Promise<Snapshot> {
|
||||
// Ensure our token is refreshed before we build the headers/params
|
||||
await this.refreshIfNeeded();
|
||||
|
||||
return {
|
||||
accessToken: this.opts.accessToken!,
|
||||
refreshToken: this.opts.refreshToken,
|
||||
expiry: this.latestTokenRefreshExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshIfNeeded(): Promise<unknown> {
|
||||
if (this.tokenRefreshPromise) {
|
||||
return this.tokenRefreshPromise;
|
||||
}
|
||||
// If we don't know the token expiry, we can't eagerly refresh
|
||||
if (!this.latestTokenRefreshExpiry) return;
|
||||
|
||||
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
|
||||
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
await this._handleUnknownToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
|
||||
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
|
||||
* @param snapshot - the snapshot returned by prepareForRequest
|
||||
* @param attempt - the number of attempts made for this request so far
|
||||
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
|
||||
*/
|
||||
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
|
||||
return this._handleUnknownToken(snapshot, attempt);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
|
||||
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (snapshot?.expiry) {
|
||||
// If our token is unknown, but it should not have expired yet, then we should not refresh
|
||||
const expiresIn = snapshot.expiry.getTime() - Date.now();
|
||||
// If it still has plenty of time left on the clock, we assume something else must be wrong and
|
||||
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
|
||||
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
|
||||
// If we have a snapshot, but the access token is the same as the current one then a refresh
|
||||
// did not happen behind us but one may be ongoing anyway
|
||||
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
|
||||
|
||||
try {
|
||||
return await this.tokenRefreshPromise;
|
||||
} finally {
|
||||
this.tokenRefreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// We may end up here if the token was refreshed in the background due to another request
|
||||
return TokenRefreshOutcome.Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
if (attempt && attempt > 1) {
|
||||
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
|
||||
await sleep(1000 * Math.min(32, 2 ** attempt));
|
||||
}
|
||||
|
||||
try {
|
||||
this.opts.logger?.debug("Attempting to refresh token");
|
||||
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
this.latestTokenRefreshExpiry = expiry;
|
||||
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
|
||||
|
||||
// successfully got new tokens
|
||||
return TokenRefreshOutcome.Success;
|
||||
} catch (error) {
|
||||
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
|
||||
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
|
||||
this.opts.logger?.error("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Logout;
|
||||
}
|
||||
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
return TokenRefreshOutcome.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -34,6 +34,11 @@ export interface Logger extends BaseLogger {
|
||||
/**
|
||||
* Create a child logger.
|
||||
*
|
||||
* This child will use the `methodFactory` of the parent, so any log extensions applied to the parent
|
||||
* at the time of calling `getChild` will be applied to the child as well.
|
||||
* It will NOT apply changes to the parent's `methodFactory` after the child was created.
|
||||
* Those changes need to be applied to the child manually.
|
||||
*
|
||||
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
|
||||
* use this as a prefix; others use a different mechanism.
|
||||
*/
|
||||
@@ -128,14 +133,24 @@ interface PrefixedLogger extends loglevel.Logger, LoggerWithLogMethod {
|
||||
*
|
||||
* @param prefix Prefix to add to each logged line. If undefined, no prefix will be added.
|
||||
*/
|
||||
function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
function getPrefixedLogger(prefix?: string): PrefixedLogger {
|
||||
const loggerName = DEFAULT_NAMESPACE + (prefix === undefined ? "" : `-${prefix}`);
|
||||
const prefixLogger = loglevel.getLogger(loggerName) as PrefixedLogger;
|
||||
|
||||
if (prefixLogger.getChild === undefined) {
|
||||
// This is a new loglevel Logger which has not been turned into a PrefixedLogger yet.
|
||||
prefixLogger.prefix = prefix;
|
||||
prefixLogger.getChild = (childPrefix): Logger => getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
prefixLogger.getChild = (childPrefix): Logger => {
|
||||
// create the new child logger
|
||||
const childLogger = getPrefixedLogger((prefix ?? "") + childPrefix);
|
||||
// Assign the methodFactory from the parent logger.
|
||||
// This is useful if we add extensions to the parent logger that modifies
|
||||
// its methodFactory. (An example extension is: storing each log to a rageshake db)
|
||||
childLogger.methodFactory = prefixLogger.methodFactory;
|
||||
// Rebuild the child logger with the new methodFactory.
|
||||
childLogger.rebuild();
|
||||
return childLogger;
|
||||
};
|
||||
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
|
||||
}
|
||||
|
||||
@@ -146,7 +161,7 @@ function getPrefixedLogger(prefix?: string): LoggerWithLogMethod {
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
*/
|
||||
export const logger = getPrefixedLogger();
|
||||
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
|
||||
|
||||
/**
|
||||
* A "span" for grouping related log lines together.
|
||||
|
||||
+115
-197
@@ -1,57 +1,49 @@
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { secureRandomBase64Url } from "../randomstring.ts";
|
||||
import { type EncryptionKeysEventContent } from "./types.ts";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/**
|
||||
* A type collecting call encryption statistics for a session.
|
||||
*/
|
||||
export type Statistics = {
|
||||
counters: {
|
||||
/**
|
||||
* The number of times we have sent a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysSent: number;
|
||||
/**
|
||||
* The number of times we have received a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
};
|
||||
totals: {
|
||||
/**
|
||||
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
|
||||
* We track the total age so that we can later calculate the average age of all keys received.
|
||||
*/
|
||||
roomEventEncryptionKeysReceivedTotalAge: number;
|
||||
};
|
||||
};
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type Statistics } from "./types.ts";
|
||||
import {
|
||||
type EnabledTransports,
|
||||
RoomAndToDeviceEvents,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
|
||||
/**
|
||||
* This interface is for testing and for making it possible to interchange the encryption manager.
|
||||
* @internal
|
||||
*/
|
||||
export interface IEncryptionManager {
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
leave(): void;
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
* Joins the encryption manager with the provided configuration.
|
||||
*
|
||||
* @param event the event to process
|
||||
* @param joinConfig - The configuration for joining encryption, or undefined
|
||||
* if no specific configuration is provided.
|
||||
*/
|
||||
join(joinConfig: EncryptionConfig | undefined): void;
|
||||
|
||||
/**
|
||||
* Leaves the encryption manager, cleaning up any associated resources.
|
||||
*/
|
||||
leave(): void;
|
||||
|
||||
/**
|
||||
* Called from the MatrixRTCSession when the memberships in this session updated.
|
||||
*
|
||||
* @param oldMemberships - The previous state of call memberships before the update.
|
||||
*/
|
||||
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
|
||||
|
||||
/**
|
||||
* Retrieves the encryption keys currently managed by the encryption manager.
|
||||
*
|
||||
* @returns A map where the keys are identifiers and the values are arrays of
|
||||
* objects containing encryption keys and their associated timestamps.
|
||||
*/
|
||||
onCallEncryptionEventReceived(event: MatrixEvent): void;
|
||||
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
|
||||
statistics: Statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +63,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
private get updateEncryptionKeyThrottle(): number {
|
||||
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
|
||||
}
|
||||
|
||||
private get makeKeyDelay(): number {
|
||||
return this.joinConfig?.makeKeyDelay ?? 3_000;
|
||||
}
|
||||
|
||||
private get useKeyDelay(): number {
|
||||
return this.joinConfig?.useKeyDelay ?? 5_000;
|
||||
}
|
||||
@@ -85,38 +79,42 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
private currentEncryptionKeyIndex = -1;
|
||||
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
|
||||
private logger: Logger;
|
||||
public constructor(
|
||||
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">,
|
||||
private room: Pick<Room, "roomId">,
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array<ArrayBufferLike>,
|
||||
encryptionKeyIndex: number,
|
||||
participantId: string,
|
||||
) => void,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
|
||||
return this.encryptionKeys;
|
||||
}
|
||||
|
||||
private joined = false;
|
||||
|
||||
public join(joinConfig: EncryptionConfig): void {
|
||||
this.joinConfig = joinConfig;
|
||||
this.joined = true;
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
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();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestSendCurrentKey();
|
||||
@@ -124,15 +122,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
// clear our encryption keys as we're done with them now (we'll
|
||||
// make new keys if we rejoin). We leave keys for other participants
|
||||
// as they may still be using the same ones.
|
||||
this.encryptionKeys.set(getParticipantId(userId, deviceId), []);
|
||||
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
|
||||
if (this.makeNewKeyTimeout !== undefined) {
|
||||
clearTimeout(this.makeNewKeyTimeout);
|
||||
@@ -146,18 +141,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.manageMediaKeys = false;
|
||||
this.joined = false;
|
||||
}
|
||||
// TODO deduplicate this method. It also is in MatrixRTCSession.
|
||||
private isMyMembership = (m: CallMembership): boolean =>
|
||||
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
|
||||
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
if (this.manageMediaKeys && this.joined) {
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
|
||||
oldMemberships
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
const newMembershipIds = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map(getParticipantIdFromMembership),
|
||||
);
|
||||
|
||||
@@ -174,11 +168,11 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (this.makeNewKeyTimeout) {
|
||||
// existing rotation in progress, so let it complete
|
||||
} else {
|
||||
logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
|
||||
}
|
||||
} else if (anyJoined) {
|
||||
logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.requestSendCurrentKey();
|
||||
} else if (oldFingerprints) {
|
||||
// does it look like any of the members have updated their memberships?
|
||||
@@ -190,7 +184,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
|
||||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
|
||||
if (candidateUpdates) {
|
||||
logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.requestSendCurrentKey();
|
||||
}
|
||||
}
|
||||
@@ -204,16 +198,17 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
* @returns The index of the new key
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): number {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
this.logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(
|
||||
this.userId,
|
||||
this.deviceId,
|
||||
encryptionKeyIndex,
|
||||
encryptionKey,
|
||||
Date.now(),
|
||||
delayBeforeUse,
|
||||
);
|
||||
return encryptionKeyIndex;
|
||||
}
|
||||
|
||||
@@ -228,7 +223,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
this.lastEncryptionKeyUpdateRequest &&
|
||||
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
|
||||
) {
|
||||
logger.info("Last encryption key event sent too recently: postponing");
|
||||
this.logger.info("Last encryption key event sent too recently: postponing");
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
this.keysEventUpdateTimeout = setTimeout(
|
||||
() => void this.sendEncryptionKeysEvent(),
|
||||
@@ -264,151 +259,67 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
|
||||
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
|
||||
if (!userId) throw new Error("No userId");
|
||||
if (!deviceId) throw new Error("No deviceId");
|
||||
|
||||
const myKeys = this.getKeysForParticipant(userId, deviceId);
|
||||
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
|
||||
|
||||
if (!myKeys) {
|
||||
logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
this.logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) {
|
||||
logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
|
||||
this.logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex;
|
||||
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
|
||||
|
||||
this.logger.info(
|
||||
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
|
||||
);
|
||||
const keyToSend = myKeys[keyIndexToSend];
|
||||
|
||||
try {
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: keyIndexToSend,
|
||||
key: encodeUnpaddedBase64(keyToSend),
|
||||
},
|
||||
],
|
||||
device_id: deviceId,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
} catch (error) {
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
const resendDelay = safeGetRetryAfterMs(matrixError, 5000);
|
||||
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
const resendDelay = safeGetRetryAfterMs(error, 5000);
|
||||
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
|
||||
} else {
|
||||
logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
this.logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onCallEncryptionEventReceived = (event: MatrixEvent): void => {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
this.encryptionKeys,
|
||||
);
|
||||
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
|
||||
}
|
||||
}
|
||||
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
|
||||
this.requestSendCurrentKey();
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
|
||||
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !this.isMyMembership(m))
|
||||
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
|
||||
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
if (this.currentEncryptionKeyIndex === -1) {
|
||||
if (this.latestGeneratedKeyIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// maximum key index is 255
|
||||
return (this.currentEncryptionKeyIndex + 1) % 256;
|
||||
return (this.latestGeneratedKeyIndex + 1) % 256;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,6 +344,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
this.logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const participantId = getParticipantId(userId, deviceId);
|
||||
@@ -445,7 +357,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
@@ -457,6 +369,15 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.userId && deviceId === this.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
// It needs to happen here because we will send the key before the timeout has passed and sending
|
||||
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
|
||||
// it will use the wrong index (index - 1)!
|
||||
this.latestGeneratedKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
@@ -465,17 +386,12 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
this.currentEncryptionKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
|
||||
}
|
||||
}
|
||||
@@ -484,7 +400,7 @@ export class EncryptionManager implements IEncryptionManager {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
this.makeNewKeyTimeout = undefined;
|
||||
logger.info("Making new sender key for key rotation");
|
||||
this.logger.info("Making new sender key for key rotation");
|
||||
const newKeyIndex = this.makeNewSenderKey(true);
|
||||
// send immediately: if we're about to start sending with a new key, it's
|
||||
// important we get it out to others as soon as we can.
|
||||
@@ -493,8 +409,10 @@ 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]);
|
||||
}
|
||||
|
||||
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 CallMembership } from "./CallMembership.ts";
|
||||
|
||||
export enum KeyTransportEvents {
|
||||
ReceivedKeys = "received_keys",
|
||||
}
|
||||
|
||||
export type KeyTransportEventsHandlerMap = {
|
||||
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
|
||||
};
|
||||
|
||||
export type KeyTransportEventListener = (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBase64Encoded: string,
|
||||
index: number,
|
||||
timestamp: number,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Generic interface for the transport used to share room keys.
|
||||
* Keys can be shared using different transports, e.g. to-device messages or room messages.
|
||||
*/
|
||||
export interface IKeyTransport {
|
||||
/**
|
||||
* Sends the current user media key to the given members.
|
||||
* @param keyBase64Encoded
|
||||
* @param index
|
||||
* @param members - The participants that should get they key
|
||||
*/
|
||||
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
|
||||
|
||||
/** Subscribe to keys from this transport. */
|
||||
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
/** Unsubscribe from keys from this transport. */
|
||||
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
|
||||
|
||||
/** Once start is called the underlying transport will subscribe to its transport system.
|
||||
* Before start is called this transport will not emit any events.
|
||||
*/
|
||||
start(): void;
|
||||
/** Once stop is called the underlying transport will unsubscribe from its transport system.
|
||||
* After stop is called this transport will not emit any events.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
import type { Status } from "./types.ts";
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
StatusChanged = "StatusChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
|
||||
* It does not imply that the room state is already configured to represent being joined.
|
||||
* It means that the Manager tries to connect or is connected. ("the manager is still active")
|
||||
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
|
||||
* @returns `true` if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isActivated(): boolean;
|
||||
/**
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
*/
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
// TypedEventEmitter methods:
|
||||
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
}
|
||||
@@ -27,7 +27,8 @@ 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 { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.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.
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { EventTimeline } from "../models/event-timeline.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
@@ -24,14 +24,20 @@ 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 { type MatrixEvent } from "../models/event.ts";
|
||||
import { MembershipManager } from "./NewMembershipManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts";
|
||||
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import type { IMembershipManager } from "./types.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import { type Statistics } from "./types.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import type { IMembershipManager } from "./IMembershipManager.ts";
|
||||
import {
|
||||
RoomAndToDeviceEvents,
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
RoomAndToDeviceTransport,
|
||||
} from "./RoomAndToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
|
||||
export enum MatrixRTCSessionEvent {
|
||||
// A member joined, left, or updated a property of their membership.
|
||||
@@ -124,6 +130,11 @@ export interface MembershipConfig {
|
||||
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
|
||||
*/
|
||||
maximumNetworkErrorRetryCount?: number;
|
||||
|
||||
/**
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptionConfig {
|
||||
@@ -157,12 +168,15 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
|
||||
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
|
||||
* This class doesn't deal with media at all, just membership & properties of a session.
|
||||
*/
|
||||
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
|
||||
export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
> {
|
||||
private membershipManager?: IMembershipManager;
|
||||
private encryptionManager: IEncryptionManager;
|
||||
private encryptionManager?: IEncryptionManager;
|
||||
// The session Id of the call, this is the call_id of the call Member event.
|
||||
private _callId: string | undefined;
|
||||
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This timeout is responsible to track any expiration. We need to know when we have to start
|
||||
* to ignore other call members. There is no callback for this. This timeout will always be configured to
|
||||
@@ -173,9 +187,15 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
public get statistics(): Statistics {
|
||||
return this.encryptionManager.statistics;
|
||||
}
|
||||
public statistics: Statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The callId (sessionId) of the call.
|
||||
@@ -193,6 +213,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
public static callMembershipsForRoom(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||
): CallMembership[] {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
@@ -296,24 +317,24 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
| "_unstable_updateDelayedEvent"
|
||||
| "sendEvent"
|
||||
| "cancelPendingEvent"
|
||||
| "encryptAndSendToDevice"
|
||||
| "off"
|
||||
| "on"
|
||||
| "decryptEventIfNeeded"
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
>,
|
||||
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
|
||||
public memberships: CallMembership[],
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this._callId = memberships[0]?.callId;
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.setExpiryTimer();
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
this.client,
|
||||
this.roomSubset,
|
||||
() => this.memberships,
|
||||
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -336,6 +357,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
}
|
||||
private reEmitter = new TypedReEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>(this);
|
||||
|
||||
/**
|
||||
* Announces this user and device as joined to the MatrixRTC session,
|
||||
@@ -353,24 +378,55 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||
return;
|
||||
} else {
|
||||
// Create MembershipManager
|
||||
// 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.membershipManager = new MembershipManager(
|
||||
joinConfig,
|
||||
this.roomSubset,
|
||||
this.client,
|
||||
() => this.getOldestMembership(),
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||
this.getOldestMembership(),
|
||||
);
|
||||
}
|
||||
// Create Encryption manager
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
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];
|
||||
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
|
||||
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
|
||||
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
|
||||
|
||||
// Expose the changes so the ui can display the currently used transport.
|
||||
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
|
||||
} 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<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
// Join!
|
||||
this.membershipManager!.join(fociPreferred, fociActive, (e) => {
|
||||
logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.logger.error("MembershipManager encountered an unrecoverable error: ", e);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipManagerError, e);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined());
|
||||
});
|
||||
@@ -391,13 +447,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
if (!this.isJoined()) {
|
||||
logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
this.logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
this.logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||
|
||||
this.encryptionManager.leave();
|
||||
this.encryptionManager!.leave();
|
||||
|
||||
const leavePromise = this.membershipManager!.leave(timeout);
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
@@ -437,7 +493,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* the keys.
|
||||
*/
|
||||
public reemitEncryptionKeys(): void {
|
||||
this.encryptionManager.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => {
|
||||
keys.forEach((key, index) => {
|
||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId);
|
||||
});
|
||||
@@ -452,7 +508,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
*/
|
||||
public getEncryptionKeys(): IterableIterator<[string, Array<Uint8Array>]> {
|
||||
const keys =
|
||||
this.encryptionManager.getEncryptionKeys() ??
|
||||
this.encryptionManager?.getEncryptionKeys() ??
|
||||
new Map<string, Array<{ key: Uint8Array; timestamp: number }>>();
|
||||
// the returned array doesn't contain the timestamps
|
||||
return Array.from(keys.entries())
|
||||
@@ -484,25 +540,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
|
||||
* This should be called each time the relevant event is received from a room timeline.
|
||||
* If the event is malformed then it will be logged and ignored.
|
||||
*
|
||||
* @param event the event to process
|
||||
*/
|
||||
public onCallEncryption = (event: MatrixEvent): void => {
|
||||
this.encryptionManager.onCallEncryptionEventReceived(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated
|
||||
* the old name might have implied to only need to call this when your own membership changes.
|
||||
*/
|
||||
public onMembershipUpdate = (): void => {
|
||||
this.recalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
@@ -535,8 +572,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
logDurationSync(logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
});
|
||||
|
||||
@@ -544,7 +581,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
// This also needs to be done if `changed` = false
|
||||
// A member might have updated their fingerprint (created_ts)
|
||||
void this.encryptionManager.onMembershipsUpdate(oldMemberships);
|
||||
void this.encryptionManager?.onMembershipsUpdate(oldMemberships);
|
||||
|
||||
this.setExpiryTimer();
|
||||
};
|
||||
|
||||
@@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "../client.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type RoomState, RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSessionManager");
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
|
||||
SessionStarted = "session_started",
|
||||
@@ -50,8 +48,10 @@ 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) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
@@ -65,7 +65,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -76,7 +75,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.off(ClientEvent.Room, this.onRoom);
|
||||
this.client.off(RoomEvent.Timeline, this.onTimeline);
|
||||
this.client.off(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
@@ -100,37 +98,6 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.getRoomSession(room).onCallEncryption(event);
|
||||
}
|
||||
private onTimeline = (event: MatrixEvent): void => {
|
||||
void this.consumeCallEncryptionEvent(event);
|
||||
};
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
@@ -138,7 +105,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
private onRoomState = (event: MatrixEvent, _state: RoomState): void => {
|
||||
const room = this.client.getRoom(event.getRoomId());
|
||||
if (!room) {
|
||||
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,17 +116,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
const isNewSession = !this.roomSessions.has(room.roomId);
|
||||
const sess = this.getRoomSession(room);
|
||||
const session = this.getRoomSession(room);
|
||||
|
||||
const wasActiveAndKnown = sess.memberships.length > 0 && !isNewSession;
|
||||
const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession;
|
||||
// This needs to be here and the event listener cannot be setup in the MatrixRTCSession,
|
||||
// because we need the update to happen between:
|
||||
// wasActiveAndKnown = session.memberships.length > 0 and
|
||||
// nowActive = session.memberships.length
|
||||
// Alternatively we would need to setup some event emission when the RTC session ended.
|
||||
session.onRTCSessionMemberUpdate();
|
||||
|
||||
sess.onRTCSessionMemberUpdate();
|
||||
|
||||
const nowActive = sess.memberships.length > 0;
|
||||
const nowActive = session.memberships.length > 0;
|
||||
|
||||
if (wasActiveAndKnown && !nowActive) {
|
||||
this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
} else if (!wasActiveAndKnown && nowActive) {
|
||||
this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,23 +19,21 @@ import { UpdateDelayedEventAction } from "../@types/requests.ts";
|
||||
import { type MatrixClient } from "../client.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger } from "../logger.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 {
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "./types.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 { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
} from "./IMembershipManager.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
|
||||
@@ -146,6 +144,8 @@ export class MembershipManager
|
||||
implements IMembershipManager
|
||||
{
|
||||
private activated = false;
|
||||
private logger: Logger;
|
||||
|
||||
public isActivated(): boolean {
|
||||
return this.activated;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export class MembershipManager
|
||||
*/
|
||||
public join(fociPreferred: Focus[], focusActive?: Focus, onError?: (error: unknown) => void): void {
|
||||
if (this.scheduler.running) {
|
||||
logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
this.logger.error("MembershipManager is already running. Ignoring join request.");
|
||||
return;
|
||||
}
|
||||
this.fociPreferred = fociPreferred;
|
||||
@@ -177,7 +177,7 @@ export class MembershipManager
|
||||
this.scheduler
|
||||
.startWithJoin()
|
||||
.catch((e) => {
|
||||
logger.error("MembershipManager stopped because: ", e);
|
||||
this.logger.error("MembershipManager stopped because: ", e);
|
||||
onError?.(e);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -201,7 +201,7 @@ export class MembershipManager
|
||||
*/
|
||||
public leave(timeout?: number): Promise<boolean> {
|
||||
if (!this.scheduler.running) {
|
||||
logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
this.logger.warn("Called MembershipManager.leave() even though the MembershipManager is not running");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -219,19 +219,18 @@ export class MembershipManager
|
||||
private leavePromiseDefer?: IDeferred<boolean>;
|
||||
|
||||
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)) {
|
||||
const userId = this.client.getUserId();
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) {
|
||||
// If one of these actions are scheduled or are getting inserted in the next iteration, we should already
|
||||
// take care of our missing membership.
|
||||
const sendingMembershipActions = [
|
||||
MembershipActionType.SendDelayedEvent,
|
||||
MembershipActionType.SendJoinEvent,
|
||||
];
|
||||
logger.warn("Missing own membership: force re-join");
|
||||
this.logger.warn("Missing own membership: force re-join");
|
||||
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
|
||||
logger.error(
|
||||
this.logger.error(
|
||||
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
|
||||
this.scheduler.actions,
|
||||
);
|
||||
@@ -253,7 +252,7 @@ export class MembershipManager
|
||||
return oldestMembership?.getPreferredFoci()[0];
|
||||
}
|
||||
} else {
|
||||
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
|
||||
this.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
|
||||
@@ -282,16 +281,38 @@ export class MembershipManager
|
||||
| "_unstable_updateDelayedEvent"
|
||||
>,
|
||||
private getOldestMembership: () => CallMembership | undefined,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
|
||||
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");
|
||||
this.deviceId = deviceId;
|
||||
this.stateKey = this.makeMembershipStateKey(userId, deviceId);
|
||||
this.state = MembershipManager.defaultState;
|
||||
this.scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
this.logger.debug(
|
||||
`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`,
|
||||
);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
this.logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
}, this.logger);
|
||||
}
|
||||
|
||||
// scheduler
|
||||
private oldStatus?: Status;
|
||||
private scheduler: ActionScheduler;
|
||||
|
||||
// MembershipManager mutable state.
|
||||
private state: MembershipManagerState;
|
||||
private static get defaultState(): MembershipManagerState {
|
||||
@@ -347,23 +368,6 @@ export class MembershipManager
|
||||
return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10;
|
||||
}
|
||||
|
||||
// Scheduler:
|
||||
private oldStatus?: Status;
|
||||
private scheduler = new ActionScheduler((type): Promise<ActionUpdate> => {
|
||||
if (this.oldStatus) {
|
||||
// we put this at the beginning of the actions scheduler loop handle callback since it is a loop this
|
||||
// is equivalent to running it at the end of the loop. (just after applying the status/action list changes)
|
||||
// This order is required because this method needs to return the action updates.
|
||||
logger.debug(`MembershipManager applied action changes. Status: ${this.oldStatus} -> ${this.status}`);
|
||||
if (this.oldStatus !== this.status) {
|
||||
this.emit(MembershipManagerEvent.StatusChanged, this.oldStatus, this.status);
|
||||
}
|
||||
}
|
||||
this.oldStatus = this.status;
|
||||
logger.debug(`MembershipManager before processing action. status=${this.oldStatus}`);
|
||||
return this.membershipLoopHandler(type);
|
||||
});
|
||||
|
||||
// LOOP HANDLER:
|
||||
private async membershipLoopHandler(type: MembershipActionType): Promise<ActionUpdate> {
|
||||
switch (type) {
|
||||
@@ -469,9 +473,9 @@ export class MembershipManager
|
||||
// This action was scheduled because we are in the process of joining
|
||||
// log and fall through
|
||||
if (this.isUnsupportedDelayedEndpoint(e)) {
|
||||
logger.info("Not using delayed event because the endpoint is not supported");
|
||||
this.logger.info("Not using delayed event because the endpoint is not supported");
|
||||
} else {
|
||||
logger.info("Not using delayed event because: " + e);
|
||||
this.logger.info("Not using delayed event because: " + e);
|
||||
}
|
||||
// On any other error we fall back to not using delayed events and send the join state event immediately
|
||||
return createInsertActionUpdate(MembershipActionType.SendJoinEvent);
|
||||
@@ -562,7 +566,7 @@ export class MembershipManager
|
||||
if (update) return update;
|
||||
|
||||
// On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting)
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Encountered unexpected error during SendScheduledDelayedLeaveEvent. Falling back to SendLeaveEvent",
|
||||
e,
|
||||
);
|
||||
@@ -696,7 +700,7 @@ export class MembershipManager
|
||||
if (typeof maxDelayAllowed === "number" && this.membershipServerSideExpiryTimeout > maxDelayAllowed) {
|
||||
this.membershipServerSideExpiryTimeoutOverride = maxDelayAllowed;
|
||||
}
|
||||
logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
this.logger.warn("Retry sending delayed disconnection event due to server timeout limitations:", error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -738,9 +742,9 @@ export class MembershipManager
|
||||
const defaultMs = 5000;
|
||||
try {
|
||||
resendDelay = error.getRetryAfterMs() ?? defaultMs;
|
||||
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
this.logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
|
||||
e,
|
||||
);
|
||||
@@ -768,7 +772,7 @@ export class MembershipManager
|
||||
const retryDurationString = this.callMemberEventRetryDelayMinimum / 1000 + "s";
|
||||
const retryCounterString = "(" + retries + "/" + this.maximumNetworkErrorRetryCount + ")";
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network local timeout error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -786,12 +790,12 @@ export class MembershipManager
|
||||
//
|
||||
// A proper fix would be to either find a place to convert the `HttpError` into a `MatrixError` and the `processError`
|
||||
// method to handle it as expected or to adjust `processError` to also process `HttpError`'s.
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"delayed event update timeout error, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
} else if (error instanceof ConnectionError) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Network connection error while sending event, retrying in " +
|
||||
retryDurationString +
|
||||
" " +
|
||||
@@ -804,7 +808,7 @@ export class MembershipManager
|
||||
error.httpStatus >= 500 &&
|
||||
error.httpStatus < 600
|
||||
) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Server error while sending event, retrying in " + retryDurationString + " " + retryCounterString,
|
||||
error,
|
||||
);
|
||||
@@ -880,7 +884,7 @@ export class MembershipManager
|
||||
return Status.Disconnected;
|
||||
}
|
||||
|
||||
logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
this.logger.error("MembershipManager has an unknown state. Actions: ", actions);
|
||||
return Status.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { logger as rootLogger } from "../logger.ts";
|
||||
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";
|
||||
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
/** @internal */
|
||||
export interface Action {
|
||||
/**
|
||||
@@ -40,6 +38,7 @@ export type ActionUpdate =
|
||||
* @internal
|
||||
*/
|
||||
export class ActionScheduler {
|
||||
private logger: Logger;
|
||||
/**
|
||||
* This is tracking the state of the scheduler loop.
|
||||
* Only used to prevent starting the loop twice.
|
||||
@@ -49,11 +48,14 @@ export class ActionScheduler {
|
||||
public constructor(
|
||||
/** This is the callback called for each scheduled action (`this.addAction()`) */
|
||||
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
|
||||
) {}
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipActionScheduler]`);
|
||||
}
|
||||
|
||||
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
|
||||
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
|
||||
logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
this.logger.error("Cannot call wakeup before calling `startWithJoin()`");
|
||||
};
|
||||
private _actions: Action[] = [];
|
||||
public get actions(): Action[] {
|
||||
@@ -69,7 +71,7 @@ export class ActionScheduler {
|
||||
*/
|
||||
public async startWithJoin(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
this.logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
@@ -92,7 +94,7 @@ export class ActionScheduler {
|
||||
|
||||
let handlerResult: ActionUpdate = {};
|
||||
if (!wakeupUpdate) {
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
|
||||
this._actions,
|
||||
`\nDate.now: "${Date.now()}`,
|
||||
@@ -121,7 +123,7 @@ export class ActionScheduler {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
this.logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
|
||||
}
|
||||
|
||||
public initiateJoin(): void {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { 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 type { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
|
||||
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
|
||||
export interface EnabledTransports {
|
||||
toDevice: boolean;
|
||||
room: boolean;
|
||||
}
|
||||
|
||||
export enum RoomAndToDeviceEvents {
|
||||
EnabledTransportsChanged = "enabled_transports_changed",
|
||||
}
|
||||
export type RoomAndToDeviceEventsHandlerMap = {
|
||||
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
|
||||
};
|
||||
/**
|
||||
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
|
||||
* The public setEnabled method allows to turn one or the other on or off on the fly.
|
||||
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
|
||||
* the user in the ui.
|
||||
*
|
||||
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
|
||||
* if a room key event was received and autoenable it.
|
||||
*/
|
||||
export class RoomAndToDeviceTransport
|
||||
extends TypedEventEmitter<
|
||||
KeyTransportEvents | RoomAndToDeviceEvents,
|
||||
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
private _enabled: EnabledTransports = { toDevice: true, room: false };
|
||||
public constructor(
|
||||
private toDeviceTransport: ToDeviceKeyTransport,
|
||||
private roomKeyTransport: RoomKeyTransport,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
|
||||
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
|
||||
this.toDeviceTransport.setParentLogger(this.logger);
|
||||
this.roomKeyTransport.setParentLogger(this.logger);
|
||||
|
||||
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
// Turn on the room transport if we receive a roomKey from another participant
|
||||
// and disable the toDevice transport.
|
||||
if (!this._enabled.room) {
|
||||
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
|
||||
this.setEnabled({ toDevice: false, room: true });
|
||||
}
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
});
|
||||
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
|
||||
if (this._enabled.toDevice) {
|
||||
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
|
||||
} else {
|
||||
this.logger.debug("To Device transport is disabled, ignoring received keys");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Set which transport type should be used to send and receive keys.*/
|
||||
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
|
||||
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
|
||||
this._enabled = enabled;
|
||||
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently enabled transports that are used to send and receive keys.*/
|
||||
public get enabled(): EnabledTransports {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always start the underlying transport since we need to enable room transport
|
||||
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
|
||||
this.roomKeyTransport.start();
|
||||
this.toDeviceTransport.start();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// always stop since it is always running
|
||||
this.roomKeyTransport.stop();
|
||||
this.toDeviceTransport.stop();
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Sending key with index ${index} to call members (count=${members.length}) via:` +
|
||||
(this._enabled.room ? "room transport" : "") +
|
||||
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
|
||||
(this._enabled.toDevice ? "to device transport" : ""),
|
||||
);
|
||||
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
|
||||
if (this._enabled.toDevice) await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
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 { MatrixClient } from "../client.ts";
|
||||
import type { EncryptionKeysEventContent, 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";
|
||||
|
||||
export class RoomKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
|
||||
}
|
||||
public constructor(
|
||||
private room: Pick<Room, "on" | "off" | "roomId">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
|
||||
>,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
public start(): void {
|
||||
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
public stop(): void {
|
||||
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
this.logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
if (!this.room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.onEncryptionEvent(event);
|
||||
}
|
||||
|
||||
/** implements {@link IKeyTransport#sendKey} */
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
// members not used in room transports as the keys are sent to all room members
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
],
|
||||
device_id: this.client.getDeviceId()!,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send call encryption keys", error);
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public onEncryptionEvent(event: MatrixEvent): void {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
this.logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
this.logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
this.logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
userId,
|
||||
deviceId,
|
||||
encryptionKey,
|
||||
encryptionKeyIndex,
|
||||
event.getTs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
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 { 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 { ClientEvent, type MatrixClient } from "../client.ts";
|
||||
import type { MatrixEvent } from "../models/event.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
/**
|
||||
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
|
||||
* to-device CS-API.
|
||||
*/
|
||||
export class ToDeviceKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private userId: string,
|
||||
private deviceId: string,
|
||||
private roomId: string,
|
||||
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
|
||||
const content: EncryptionKeysToDeviceEventContent = {
|
||||
keys: {
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
room_id: this.roomId,
|
||||
member: {
|
||||
claimed_device_id: this.deviceId,
|
||||
},
|
||||
session: {
|
||||
call_id: "",
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
},
|
||||
};
|
||||
|
||||
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!,
|
||||
deviceId: member.deviceId!,
|
||||
};
|
||||
});
|
||||
|
||||
if (targets.length > 0) {
|
||||
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
} else {
|
||||
this.logger.warn("No targets found for sending key");
|
||||
}
|
||||
}
|
||||
|
||||
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
|
||||
// The event has already been validated at this point.
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
|
||||
// What is this, and why is it needed?
|
||||
// Also to device events do not have an origin server ts
|
||||
const now = Date.now();
|
||||
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// TODO this is claimed information
|
||||
fromUser,
|
||||
// TODO: This is claimed information
|
||||
content.member.claimed_device_id!,
|
||||
content.keys.key,
|
||||
content.keys.index,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
|
||||
// Ignore this is not a call encryption event
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Not possible to check if the event is encrypted or not
|
||||
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
|
||||
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
|
||||
// // WARN: The call keys were sent in clear. Ignore them
|
||||
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const content = this.getValidEventContent(event);
|
||||
if (!content) return;
|
||||
|
||||
if (!event.getSender()) return;
|
||||
|
||||
this.receiveCallKeyEvent(event.getSender()!, content);
|
||||
};
|
||||
|
||||
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
|
||||
const content = event.getContent();
|
||||
const roomId = content.room_id;
|
||||
if (!roomId) {
|
||||
// Invalid event
|
||||
this.logger.warn("Malformed Event: invalid call encryption keys event, no roomId");
|
||||
return;
|
||||
}
|
||||
if (roomId !== this.roomId) {
|
||||
this.logger.warn("Malformed Event: Mismatch roomId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
|
||||
this.logger.warn("Malformed Event: Missing keys field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.member || !content.member.claimed_device_id) {
|
||||
this.logger.warn("Malformed Event: Missing claimed_device_id");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO check for session related fields once the to-device encryption uses the new format.
|
||||
return content as EncryptionKeysToDeviceEventContent;
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export * from "./LivekitFocus.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
export { Status, MembershipManagerEvent } from "./types.ts";
|
||||
export { Status } from "./types.ts";
|
||||
export { MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
+41
-67
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
import type { IMentions } from "../matrix.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import type { Focus } from "./focus.ts";
|
||||
|
||||
export interface EncryptionKeyEntry {
|
||||
index: number;
|
||||
@@ -29,6 +28,24 @@ export interface EncryptionKeysEventContent {
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
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
|
||||
claimed_device_id: string;
|
||||
// user_id: string
|
||||
};
|
||||
room_id: string;
|
||||
session: {
|
||||
application: string;
|
||||
call_id: string;
|
||||
scope: string;
|
||||
};
|
||||
// Why is this needed?
|
||||
sent_ts?: number;
|
||||
}
|
||||
|
||||
export type CallNotifyType = "ring" | "notify";
|
||||
|
||||
export interface ICallNotifyContent {
|
||||
@@ -49,71 +66,28 @@ export enum Status {
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export enum MembershipManagerEvent {
|
||||
StatusChanged = "StatusChanged",
|
||||
}
|
||||
|
||||
export type MembershipManagerEventHandlerMap = {
|
||||
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
|
||||
/**
|
||||
* A type collecting call encryption statistics for a session.
|
||||
*/
|
||||
export type Statistics = {
|
||||
counters: {
|
||||
/**
|
||||
* The number of times we have sent a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysSent: number;
|
||||
/**
|
||||
* The number of times we have received a room event containing encryption keys.
|
||||
*/
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
};
|
||||
totals: {
|
||||
/**
|
||||
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
|
||||
* We track the total age so that we can later calculate the average age of all keys received.
|
||||
*/
|
||||
roomEventEncryptionKeysReceivedTotalAge: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface defines what a MembershipManager uses and exposes.
|
||||
* This interface is what we use to write tests and allows changing the actual implementation
|
||||
* without breaking tests because of some internal method renaming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IMembershipManager {
|
||||
/**
|
||||
* If we are trying to join, or have successfully joined the session.
|
||||
* It does not reflect if the room state is already configured to represent us being joined.
|
||||
* It only means that the Manager should be trying to connect or to disconnect running.
|
||||
* The Manager is still running right after isJoined becomes false to send the disconnect events.
|
||||
* @returns true if we intend to be participating in the MatrixRTC session
|
||||
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
|
||||
*/
|
||||
isJoined(): boolean;
|
||||
/**
|
||||
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
|
||||
* It does not imply that the room state is already configured to represent being joined.
|
||||
* It means that the Manager tries to connect or is connected. ("the manager is still active")
|
||||
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
|
||||
* @returns `true` if we intend to be participating in the MatrixRTC session
|
||||
*/
|
||||
isActivated(): boolean;
|
||||
/**
|
||||
* Get the actual connection status of the manager.
|
||||
*/
|
||||
get status(): Status;
|
||||
/**
|
||||
* The current status while the manager is activated
|
||||
*/
|
||||
/**
|
||||
* Start sending all necessary events to make this user participate in the RTC session.
|
||||
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
|
||||
* @param fociActive the active focus to use in the joined RTC membership event.
|
||||
* @throws can throw if it exceeds a configured maximum retry.
|
||||
*/
|
||||
join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void;
|
||||
/**
|
||||
* Send all necessary events to make this user leave the RTC session.
|
||||
* @param timeout the maximum duration in ms until the promise is forced to resolve.
|
||||
* @returns It resolves with true in case the leave was sent successfully.
|
||||
* It resolves with false in case we hit the timeout before sending successfully.
|
||||
*/
|
||||
leave(timeout?: number): Promise<boolean>;
|
||||
/**
|
||||
* Call this if the MatrixRTC session members have changed.
|
||||
*/
|
||||
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
|
||||
/**
|
||||
* The used active focus in the currently joined session.
|
||||
* @returns the used active focus in the currently joined session or undefined if not joined.
|
||||
*/
|
||||
getActiveFocus(): Focus | undefined;
|
||||
|
||||
// TypedEventEmitter methods:
|
||||
on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this;
|
||||
}
|
||||
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
|
||||
m.sender === userId && m.deviceId === deviceId;
|
||||
|
||||
@@ -139,6 +139,7 @@ export class OidcTokenRefresher {
|
||||
profile: this.idTokenClaims,
|
||||
};
|
||||
|
||||
const requestStart = Date.now();
|
||||
const response = await this.oidcClient.useRefreshToken({
|
||||
state: refreshTokenState,
|
||||
timeoutInSeconds: 300,
|
||||
@@ -147,7 +148,9 @@ export class OidcTokenRefresher {
|
||||
const tokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
};
|
||||
// We use the request start time to calculate the expiry time as we don't know when the server received our request
|
||||
expiry: response.expires_in ? new Date(requestStart + response.expires_in * 1000) : undefined,
|
||||
} satisfies AccessTokens;
|
||||
|
||||
await this.persistTokens(tokens);
|
||||
|
||||
|
||||
@@ -1465,7 +1465,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
// Disable backup, and delete all the backups from the server
|
||||
await this.backupManager.deleteAllKeyBackupVersions();
|
||||
|
||||
this.deleteSecretStorage();
|
||||
await this.deleteSecretStorage();
|
||||
|
||||
// Reset the cross-signing keys
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning({
|
||||
|
||||
@@ -1,39 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Method decorator to ensure that only one instance of the method is running at a time,
|
||||
* and any concurrent calls will return the same promise as the original call.
|
||||
* After execution is complete a new call will be able to run the method again.
|
||||
*/
|
||||
export function singleAsyncExecution<This, Args extends unknown[], Return>(
|
||||
target: (this: This, ...args: Args) => Promise<Return>,
|
||||
): (this: This, ...args: Args) => Promise<Return> {
|
||||
let promise: Promise<Return> | undefined;
|
||||
|
||||
async function replacementMethod(this: This, ...args: Args): Promise<Return> {
|
||||
if (promise) return promise;
|
||||
try {
|
||||
promise = target.call(this, ...args);
|
||||
await promise;
|
||||
return promise;
|
||||
} finally {
|
||||
promise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return replacementMethod;
|
||||
}
|
||||
+1
-1
@@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevices.
|
||||
// TODO: Here we were sending the event to the opponent's device as a to-device message with MatrixClient.encryptAndSendToDevice.
|
||||
// However due to the switch to Rust cryptography we need to migrate to the new encryptToDeviceMessages API.
|
||||
throw new Error("Unimplemented");
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user