Compare commits
33 Commits
v28.1.0-rc.1
...
v28.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a491508543 | |||
| 0abba3e626 | |||
| d669ddfab2 | |||
| 9caa38d386 | |||
| 1c16b5cae6 | |||
| cb375e1351 | |||
| 5e542b3869 | |||
| c9435af637 | |||
| 40168d4419 | |||
| 6d118008b6 | |||
| 1503acb30a | |||
| 1b8507c060 | |||
| d95b5ab27a | |||
| 658e7b1be3 | |||
| 95110eb889 | |||
| 9fbcef556e | |||
| b68ad00394 | |||
| 6836720e1e | |||
| 6f517478df | |||
| 35ba4074de | |||
| c7827d971c | |||
| f963ca5562 | |||
| 8c30b0d12c | |||
| 5d4334ba4c | |||
| 7e691bf700 | |||
| 0700e86f58 | |||
| 6c307d4c63 | |||
| 88ec0e3e17 | |||
| 015e9a5be7 | |||
| 2918d686ae | |||
| 327c18ddc1 | |||
| 8cdd8e882b | |||
| 76e0d5a896 |
@@ -3,4 +3,6 @@
|
||||
/package.json @matrix-org/element-web-app-team
|
||||
/yarn.lock @matrix-org/element-web-app-team
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/src/matrixrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/webrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/matrixrtc @matrix-org/element-call-reviewers
|
||||
|
||||
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.78.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.79.0
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.78.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.79.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2
|
||||
uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 # v2
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: _docs
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
node: [18, latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
|
||||
+18
-1
@@ -1,9 +1,26 @@
|
||||
Changes in [28.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.1.0-rc.1) (2023-09-05)
|
||||
Changes in [28.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.2.0) (2023-09-26)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Implement `getEncryptionInfoForEvent` and deprecate `getEventEncryptionInfo` ([\#3693](https://github.com/matrix-org/matrix-js-sdk/pull/3693)).
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Delete knocked room when knock membership changes ([\#3729](https://github.com/matrix-org/matrix-js-sdk/pull/3729)). Contributed by @maheichyk.
|
||||
* Introduce MatrixRTCSession lower level group call primitive ([\#3663](https://github.com/matrix-org/matrix-js-sdk/pull/3663)).
|
||||
* Sync knock rooms ([\#3703](https://github.com/matrix-org/matrix-js-sdk/pull/3703)). Contributed by @maheichyk.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Dont access indexed db when undefined ([\#3707](https://github.com/matrix-org/matrix-js-sdk/pull/3707)). Contributed by @finsterwalder.
|
||||
* Don't reset unread count when adding a synthetic receipt ([\#3706](https://github.com/matrix-org/matrix-js-sdk/pull/3706)). Fixes #3684. Contributed by @andybalaam.
|
||||
|
||||
Changes in [28.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.1.0) (2023-09-12)
|
||||
============================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate `MatrixClient.checkUserTrust` ([\#3691](https://github.com/matrix-org/matrix-js-sdk/pull/3691)).
|
||||
* Deprecate `MatrixClient.{prepare,create}KeyBackupVersion` in favour of new `CryptoApi.resetKeyBackup` API ([\#3689](https://github.com/matrix-org/matrix-js-sdk/pull/3689)).
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Allow calls without ICE/TURN/STUN servers ([\#3695](https://github.com/matrix-org/matrix-js-sdk/pull/3695)).
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "28.1.0-rc.1",
|
||||
"version": "28.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -55,14 +55,14 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.1",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"loglevel": "^1.7.1",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.5.0",
|
||||
"matrix-widget-api": "^1.6.0",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
@@ -101,7 +101,7 @@
|
||||
"browserify-swap": "^0.2.2",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.47.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
|
||||
+341
-234
@@ -57,6 +57,7 @@ import {
|
||||
RoomStateEvent,
|
||||
} from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { DecryptionError } from "../../../src/crypto/algorithms";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -629,6 +631,107 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(decryptedEvent.getContent().body).toEqual("42");
|
||||
});
|
||||
|
||||
describe("Unable to decrypt error codes", function () {
|
||||
it("Encryption fails with expected UISI error", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const awaitUISI = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
if (error.code == "MEGOLM_UNKNOWN_INBOUND_SESSION_ID") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitUISI;
|
||||
});
|
||||
|
||||
it("Encryption fails with expected Unknown Index error", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const awaitUnknownIndex = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
if (error.code == "OLM_UNKNOWN_MESSAGE_INDEX") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]);
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitUnknownIndex;
|
||||
});
|
||||
|
||||
it("Encryption fails with Unable to decrypt for other errors", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
await aliceClient.getCrypto()!.importRoomKeys([testData.MEGOLM_SESSION_DATA]);
|
||||
|
||||
const awaitDecryptionError = new Promise<void>((resolve) => {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
const error = err as DecryptionError;
|
||||
// rust and libolm can't have an exact 1:1 mapping for all errors,
|
||||
// but some errors are part of API and should match
|
||||
if (
|
||||
error.code != "MEGOLM_UNKNOWN_INBOUND_SESSION_ID" &&
|
||||
error.code != "OLM_UNKNOWN_MESSAGE_INDEX"
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const malformedEvent: Partial<IEvent> = JSON.parse(JSON.stringify(testData.ENCRYPTED_EVENT));
|
||||
malformedEvent.content!.ciphertext = "AwgAEnAkBmciEAyhh1j6DCk29UXJ7kv/kvayUNfuNT0iAioLxcXjFX";
|
||||
|
||||
// Alice gets both the events in a single sync
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[testData.TEST_ROOM_ID]: { timeline: { events: [malformedEvent] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitDecryptionError;
|
||||
});
|
||||
});
|
||||
|
||||
it("Alice receives a megolm message before the session keys", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
@@ -2179,7 +2282,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapSecretStorage", () => {
|
||||
describe("Secret Storage and Key Backup", () => {
|
||||
/**
|
||||
* Create a fake secret storage key
|
||||
* Async because `bootstrapSecretStorage` expect an async method
|
||||
@@ -2189,6 +2292,61 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
privateKey: Uint8Array.of(32, 33),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
createSecretStorageKey.mockClear();
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Resolved when the cross signing key is uploaded
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content.encrypted);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send in the sync response the provided `secretStorageKey` into the account_data field
|
||||
* The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync
|
||||
* @param secretStorageKey
|
||||
*/
|
||||
function sendSyncResponse(secretStorageKey: string) {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.secret_storage.default_key",
|
||||
content: {
|
||||
key: secretStorageKey,
|
||||
},
|
||||
},
|
||||
// Needed for secretStorage.getKey or secretStorage.hasKey
|
||||
{
|
||||
type: `m.secret_storage.key.${secretStorageKey}`,
|
||||
content: {
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)`
|
||||
* Resolved when a key is uploaded (ie in `body.content.key`)
|
||||
@@ -2212,25 +2370,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
|
||||
* Resolved when the cross signing key is uploaded
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
|
||||
*/
|
||||
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content.encrypted);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function awaitMegolmBackupKeyUpload(): Promise<Record<string, {}>> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the megolm backup key is uploaded
|
||||
@@ -2298,78 +2437,20 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await backupStatusUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send in the sync response the provided `secretStorageKey` into the account_data field
|
||||
* The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events
|
||||
* https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync
|
||||
* @param secretStorageKey
|
||||
*/
|
||||
function sendSyncResponse(secretStorageKey: string) {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.secret_storage.default_key",
|
||||
content: {
|
||||
key: secretStorageKey,
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
// Needed for secretStorage.getKey or secretStorage.hasKey
|
||||
{
|
||||
type: `m.secret_storage.key.${secretStorageKey}`,
|
||||
content: {
|
||||
key: secretStorageKey,
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
],
|
||||
describe("bootstrapSecretStorage", () => {
|
||||
newBackendOnly(
|
||||
"should throw an error if we are unable to create a key because createSecretStorageKey is not set",
|
||||
async () => {
|
||||
await expect(
|
||||
aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }),
|
||||
).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set");
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
createSecretStorageKey.mockClear();
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"should throw an error if we are unable to create a key because createSecretStorageKey is not set",
|
||||
async () => {
|
||||
await expect(
|
||||
aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }),
|
||||
).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set");
|
||||
},
|
||||
);
|
||||
|
||||
it("should create a new key", async () => {
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
|
||||
// Check that the uploaded key in stored in the secret storage
|
||||
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
|
||||
// Check that the uploaded key is the default key
|
||||
expect(defaultKeyId).toBe(secretStorageKey);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set",
|
||||
async () => {
|
||||
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
it("should create a new key", async () => {
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
@@ -2377,178 +2458,204 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
|
||||
// Check that the uploaded key in stored in the secret storage
|
||||
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
|
||||
// Check that the uploaded key is the default key
|
||||
expect(defaultKeyId).toBe(secretStorageKey);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set",
|
||||
async () => {
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Call again bootstrapSecretStorage
|
||||
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
|
||||
// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage", async () => {
|
||||
let bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Call again bootstrapSecretStorage
|
||||
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
|
||||
bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
// Wait for the key to be uploaded in the account data
|
||||
secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
it("should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage", async () => {
|
||||
let bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
// createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
it("should upload cross signing keys", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// Call again bootstrapSecretStorage
|
||||
bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
// Before setting up secret-storage, bootstrap cross-signing, so that the client has cross-signing keys.
|
||||
await aliceClient.getCrypto()!.bootstrapCrossSigning({});
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
// Now, when we bootstrap secret-storage, the cross-signing keys should be uploaded.
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
|
||||
// Wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
|
||||
// createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call
|
||||
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
|
||||
// Wait for the cross signing keys to be uploaded
|
||||
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
|
||||
awaitCrossSigningKeyUpload("master"),
|
||||
awaitCrossSigningKeyUpload("user_signing"),
|
||||
awaitCrossSigningKeyUpload("self_signing"),
|
||||
]);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey`
|
||||
expect(masterKey[secretStorageKey]).toBeDefined();
|
||||
expect(userSigningKey[secretStorageKey]).toBeDefined();
|
||||
expect(selfSigningKey[secretStorageKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a new megolm backup", async () => {
|
||||
const backupVersion = "abc";
|
||||
await bootstrapSecurity(backupVersion);
|
||||
|
||||
// Expect a backup to be available and used
|
||||
const activeBackup = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
expect(activeBackup).toStrictEqual(backupVersion);
|
||||
|
||||
// check that there is a MSK signature
|
||||
const signatures = (await aliceClient.getCrypto()!.checkKeyBackupAndEnable())!.backupInfo.auth_data!
|
||||
.signatures;
|
||||
expect(signatures).toBeDefined();
|
||||
expect(signatures![aliceClient.getUserId()!]).toBeDefined();
|
||||
const mskId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.Master)!;
|
||||
expect(signatures![aliceClient.getUserId()!][`ed25519:${mskId}`]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should upload cross signing keys", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
describe("Manage Key Backup", () => {
|
||||
it("Reset key backup should create a new backup and update 4S", async () => {
|
||||
// First set up 4S and key backup
|
||||
const backupVersion = "1";
|
||||
await bootstrapSecurity(backupVersion);
|
||||
|
||||
// Before setting up secret-storage, bootstrap cross-signing, so that the client has cross-signing keys.
|
||||
await aliceClient.getCrypto()!.bootstrapCrossSigning({});
|
||||
const currentVersion = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
const currentBackupKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
|
||||
// Now, when we bootstrap secret-storage, the cross-signing keys should be uploaded.
|
||||
const bootstrapPromise = aliceClient
|
||||
.getCrypto()!
|
||||
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
|
||||
// we will call reset backup, it should delete the existing one, then setup a new one
|
||||
// Let's mock for that
|
||||
|
||||
// Wait for the key to be uploaded in the account data
|
||||
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
|
||||
// Mock delete and replace the GET to return 404 as soon as called
|
||||
const awaitDeleteCalled = new Promise<void>((resolve) => {
|
||||
fetchMock.delete(
|
||||
"express:/_matrix/client/v3/room_keys/version/:version",
|
||||
(url: string, options: RequestInit) => {
|
||||
fetchMock.get(
|
||||
"path:/_matrix/client/v3/room_keys/version",
|
||||
{
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
resolve();
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
// Return the newly created key in the sync response
|
||||
sendSyncResponse(secretStorageKey);
|
||||
const newVersion = "2";
|
||||
fetchMock.post(
|
||||
"path:/_matrix/client/v3/room_keys/version",
|
||||
(url, request) => {
|
||||
const backupData: KeyBackupInfo = JSON.parse(request.body?.toString() ?? "{}");
|
||||
backupData.version = newVersion;
|
||||
backupData.count = 0;
|
||||
backupData.etag = "zer";
|
||||
|
||||
// Wait for the cross signing keys to be uploaded
|
||||
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
|
||||
awaitCrossSigningKeyUpload("master"),
|
||||
awaitCrossSigningKeyUpload("user_signing"),
|
||||
awaitCrossSigningKeyUpload("self_signing"),
|
||||
]);
|
||||
|
||||
// Finally, wait for bootstrapSecretStorage to finished
|
||||
await bootstrapPromise;
|
||||
|
||||
// Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey`
|
||||
expect(masterKey[secretStorageKey]).toBeDefined();
|
||||
expect(userSigningKey[secretStorageKey]).toBeDefined();
|
||||
expect(selfSigningKey[secretStorageKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a new megolm backup", async () => {
|
||||
const backupVersion = "abc";
|
||||
await bootstrapSecurity(backupVersion);
|
||||
|
||||
// Expect a backup to be available and used
|
||||
const activeBackup = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
expect(activeBackup).toStrictEqual(backupVersion);
|
||||
|
||||
// check that there is a MSK signature
|
||||
const signatures = (await aliceClient.getCrypto()!.checkKeyBackupAndEnable())!.backupInfo.auth_data!
|
||||
.signatures;
|
||||
expect(signatures).toBeDefined();
|
||||
expect(signatures![aliceClient.getUserId()!]).toBeDefined();
|
||||
const mskId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.Master)!;
|
||||
expect(signatures![aliceClient.getUserId()!][`ed25519:${mskId}`]).toBeDefined();
|
||||
});
|
||||
|
||||
it("Reset key backup should create a new backup and update 4S", async () => {
|
||||
// First set up 4S and key backup
|
||||
const backupVersion = "1";
|
||||
await bootstrapSecurity(backupVersion);
|
||||
|
||||
const currentVersion = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
const currentBackupKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
|
||||
// we will call reset backup, it should delete the existing one, then setup a new one
|
||||
// Let's mock for that
|
||||
|
||||
// Mock delete and replace the GET to return 404 as soon as called
|
||||
const awaitDeleteCalled = new Promise<void>((resolve) => {
|
||||
fetchMock.delete(
|
||||
"express:/_matrix/client/v3/room_keys/version/:version",
|
||||
(url: string, options: RequestInit) => {
|
||||
fetchMock.get(
|
||||
"path:/_matrix/client/v3/room_keys/version",
|
||||
{
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
resolve();
|
||||
return {};
|
||||
// update get call with new version
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupData, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
return {
|
||||
version: backupVersion,
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
const newVersion = "2";
|
||||
fetchMock.post(
|
||||
"path:/_matrix/client/v3/room_keys/version",
|
||||
(url, request) => {
|
||||
const backupData: KeyBackupInfo = JSON.parse(request.body?.toString() ?? "{}");
|
||||
backupData.version = newVersion;
|
||||
backupData.count = 0;
|
||||
backupData.etag = "zer";
|
||||
|
||||
// update get call with new version
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupData, {
|
||||
overwriteRoutes: true,
|
||||
const newBackupStatusUpdate = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
return {
|
||||
version: backupVersion,
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const newBackupStatusUpdate = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const newBackupUploadPromise = awaitMegolmBackupKeyUpload();
|
||||
|
||||
await aliceClient.getCrypto()!.resetKeyBackup();
|
||||
await awaitDeleteCalled;
|
||||
await newBackupStatusUpdate;
|
||||
await newBackupUploadPromise;
|
||||
|
||||
const nextVersion = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
const nextKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
|
||||
expect(nextVersion).toBeDefined();
|
||||
expect(nextVersion).not.toEqual(currentVersion);
|
||||
expect(nextKey).not.toEqual(currentBackupKey);
|
||||
|
||||
// The `deleteKeyBackupVersion` API is deprecated but has been modified to work with both crypto backend
|
||||
// ensure that it works anyhow
|
||||
await aliceClient.deleteKeyBackupVersion(nextVersion!);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
// XXX Legacy crypto does not update 4S when doing that; should ensure that rust implem does it.
|
||||
expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
|
||||
const newBackupUploadPromise = awaitMegolmBackupKeyUpload();
|
||||
|
||||
await aliceClient.getCrypto()!.resetKeyBackup();
|
||||
await awaitDeleteCalled;
|
||||
await newBackupStatusUpdate;
|
||||
await newBackupUploadPromise;
|
||||
|
||||
const nextVersion = await aliceClient.getCrypto()!.getActiveSessionBackupVersion();
|
||||
const nextKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
|
||||
expect(nextVersion).toBeDefined();
|
||||
expect(nextVersion).not.toEqual(currentVersion);
|
||||
expect(nextKey).not.toEqual(currentBackupKey);
|
||||
|
||||
// The `deleteKeyBackupVersion` API is deprecated but has been modified to work with both crypto backend
|
||||
// ensure that it works anyhow
|
||||
await aliceClient.deleteKeyBackupVersion(nextVersion!);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
// XXX Legacy crypto does not update 4S when doing that; should ensure that rust implem does it.
|
||||
expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
@@ -29,51 +28,11 @@ import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
|
||||
const ROOM_ID = "!ROOM:ID";
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
|
||||
const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
|
||||
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
|
||||
|
||||
const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
type: "m.room.encrypted",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
sender_key: "SENDER_CURVE25519",
|
||||
session_id: SESSION_ID,
|
||||
ciphertext:
|
||||
"AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N" +
|
||||
"CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl" +
|
||||
"mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs",
|
||||
},
|
||||
room_id: "!ROOM:ID",
|
||||
event_id: "$event1",
|
||||
origin_server_ts: 1507753886000,
|
||||
};
|
||||
|
||||
const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
|
||||
first_message_index: 0,
|
||||
forwarded_count: 0,
|
||||
is_verified: false,
|
||||
session_data: {
|
||||
ciphertext:
|
||||
"2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw" +
|
||||
"6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ" +
|
||||
"Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9" +
|
||||
"SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy" +
|
||||
"Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF" +
|
||||
"ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV" +
|
||||
"4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv" +
|
||||
"C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe" +
|
||||
"Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf" +
|
||||
"QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy" +
|
||||
"iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg",
|
||||
mac: "5lxYBHQU80M",
|
||||
ephemeral: "/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14",
|
||||
},
|
||||
};
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
@@ -139,7 +98,8 @@ function mockUploadEmitter(
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
// const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
// const newBackendOnly = backend === "libolm" ? test.skip : test;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
/** an object which intercepts `/sync` requests on the test homeserver */
|
||||
@@ -189,21 +149,24 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
return client;
|
||||
}
|
||||
|
||||
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [ENCRYPTED_EVENT],
|
||||
events: [testData.ENCRYPTED_EVENT],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
@@ -217,7 +180,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.checkKeyBackup();
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
|
||||
// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
@@ -226,7 +189,118 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent()).toEqual("testytest");
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
let onKeyCached: () => void;
|
||||
const awaitKeyCached = new Promise<void>((resolve) => {
|
||||
onKeyCached = resolve;
|
||||
});
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
|
||||
await awaitKeyCached;
|
||||
});
|
||||
|
||||
it("recover specific session from backup", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Fails on bad recovery key", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
await expect(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
"EsTx A7Xn aNFF k3jH zpV3 MQoN LJEg mscC HecF 982L wC77 mYQD",
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupLoop", () => {
|
||||
|
||||
@@ -87,4 +87,19 @@ describe("MatrixClient.clearStores", () => {
|
||||
await matrixClient.clearStores();
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not fail in environments without indexedDB", async () => {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = undefined!;
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores();
|
||||
// No error thrown in clearStores
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,6 +223,121 @@ describe("MatrixClient syncing", () => {
|
||||
expect(fires).toBe(3);
|
||||
});
|
||||
|
||||
it("should emit RoomEvent.MyMembership for knock->leave->knock cycles", async () => {
|
||||
await client!.initCrypto();
|
||||
|
||||
const roomId = "!cycles:example.org";
|
||||
|
||||
// First sync: an knock
|
||||
const knockSyncRoomSection = {
|
||||
knock: {
|
||||
[roomId]: {
|
||||
knock_state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// Second sync: a leave (reject of some kind)
|
||||
httpBackend!.when("POST", "/leave").respond(200, {});
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: {
|
||||
leave: {
|
||||
[roomId]: {
|
||||
account_data: { events: [] },
|
||||
ephemeral: { events: [] },
|
||||
state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
// XXX: And other fields required on an event
|
||||
},
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
limited: false,
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
// XXX: And other fields required on an event
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Third sync: another knock
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// First fire: an initial knock
|
||||
let fires = 0;
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
// Room, string, string
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("knock");
|
||||
expect(oldMembership).toBeFalsy();
|
||||
|
||||
// Second fire: a leave
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("leave");
|
||||
expect(oldMembership).toBe("knock");
|
||||
|
||||
// Third/final fire: a second knock
|
||||
client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
expect(membership).toBe("knock");
|
||||
expect(oldMembership).toBe("leave");
|
||||
});
|
||||
});
|
||||
|
||||
// For maximum safety, "leave" the room after we register the handler
|
||||
client!.leave(roomId);
|
||||
});
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(fires).toBe(3);
|
||||
});
|
||||
|
||||
it("should honour lazyLoadMembers if user is not a guest", () => {
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
@@ -293,6 +408,46 @@ describe("MatrixClient syncing", () => {
|
||||
expect(fires).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit ClientEvent.Room when knocked while crypto is disabled", async () => {
|
||||
const roomId = "!knock:example.org";
|
||||
|
||||
// First sync: a knock
|
||||
const knockSyncRoomSection = {
|
||||
knock: {
|
||||
[roomId]: {
|
||||
knock_state: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.member",
|
||||
state_key: selfUserId,
|
||||
content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: knockSyncRoomSection,
|
||||
});
|
||||
|
||||
// First fire: an initial knock
|
||||
let fires = 0;
|
||||
client!.once(ClientEvent.Room, (room) => {
|
||||
fires++;
|
||||
expect(room.roomId).toBe(roomId);
|
||||
});
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
expect(fires).toBe(1);
|
||||
});
|
||||
|
||||
it("should work when all network calls fail", async () => {
|
||||
httpBackend!.expectedRequests = [];
|
||||
httpBackend!.when("GET", "").fail(0, new Error("CORS or something"));
|
||||
@@ -358,6 +513,7 @@ describe("MatrixClient syncing", () => {
|
||||
join: {},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ describe("MatrixClient syncing", () => {
|
||||
},
|
||||
[Category.Leave]: {},
|
||||
[Category.Invite]: {},
|
||||
[Category.Knock]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,10 +26,15 @@ python -m venv env
|
||||
|
||||
import base64
|
||||
import json
|
||||
import base58
|
||||
|
||||
from canonicaljson import encode_canonical_json
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from cryptography.hazmat.primitives import hashes, padding, hmac
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from random import randbytes, seed
|
||||
|
||||
ALICE_DATA = {
|
||||
@@ -38,6 +43,8 @@ ALICE_DATA = {
|
||||
"TEST_ROOM_ID": "!room:id",
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
# any 32-byte string can be an curve25519 private key.
|
||||
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"deadmuledeadmuledeadmuledeadmule",
|
||||
|
||||
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"doyouspeakwhaaaaaaaaaaaaaaaaaale",
|
||||
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"useruseruseruseruseruseruseruser",
|
||||
@@ -55,6 +62,8 @@ BOB_DATA = {
|
||||
"TEST_ROOM_ID": "!room:id",
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"Deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
# any 32-byte string can be an curve25519 private key.
|
||||
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"Deadmuledeadmuledeadmuledeadmule",
|
||||
|
||||
"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Doyouspeakwhaaaaaaaaaaaaaaaaaale",
|
||||
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Useruseruseruseruseruseruseruser",
|
||||
@@ -75,8 +84,8 @@ def main() -> None:
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
import {{ KeyBackupInfo }} from "../../../src/crypto-api";
|
||||
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
|
||||
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -97,6 +106,11 @@ def build_test_data(user_data, prefix = "") -> str:
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
user_data["TEST_DEVICE_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
|
||||
device_curve_key = x25519.X25519PrivateKey.from_private_bytes(
|
||||
user_data["TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES"]
|
||||
)
|
||||
|
||||
b64_public_key = encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
@@ -164,9 +178,10 @@ def build_test_data(user_data, prefix = "") -> str:
|
||||
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": sig}
|
||||
}
|
||||
|
||||
set_of_exported_room_keys = [build_exported_megolm_key(), build_exported_megolm_key()]
|
||||
set_of_exported_room_keys = [build_exported_megolm_key(device_curve_key)[0], build_exported_megolm_key(device_curve_key)[0]]
|
||||
|
||||
additional_exported_room_key = build_exported_megolm_key()
|
||||
additional_exported_room_key, additional_exported_ed_key = build_exported_megolm_key(device_curve_key)
|
||||
ratcheted_exported_room_key = symetric_ratchet_step_of_megolm_key(additional_exported_room_key, additional_exported_ed_key)
|
||||
|
||||
otk_to_sign = {
|
||||
"key": user_data['OTK']
|
||||
@@ -186,6 +201,13 @@ def build_test_data(user_data, prefix = "") -> str:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
backed_up_room_key = encrypt_megolm_key_for_backup(additional_exported_room_key, backup_decryption_key.public_key())
|
||||
|
||||
clear_event, encrypted_event = generate_encrypted_event_content(additional_exported_room_key, additional_exported_ed_key, device_curve_key)
|
||||
|
||||
backup_recovery_key = export_recovery_key(user_data["B64_BACKUP_DECRYPTION_KEY"])
|
||||
|
||||
return f"""\
|
||||
export const {prefix}TEST_USER_ID = "{user_data['TEST_USER_ID']}";
|
||||
export const {prefix}TEST_DEVICE_ID = "{user_data['TEST_DEVICE_ID']}";
|
||||
@@ -220,9 +242,15 @@ export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult>
|
||||
json.dumps(build_cross_signing_keys_data(user_data), indent=4)
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
|
||||
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
|
||||
|
||||
@@ -236,8 +264,19 @@ export const {prefix}MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
json.dumps(additional_exported_room_key, indent=4)
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
/** A ratcheted version of {prefix}MEGOLM_SESSION_DATA */
|
||||
export const {prefix}RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
json.dumps(ratcheted_exported_room_key, indent=4)
|
||||
};
|
||||
|
||||
/** The key from {prefix}MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const {prefix}CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {json.dumps(backed_up_room_key, indent=4)};
|
||||
|
||||
/** A test clear event */
|
||||
export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, indent=4)};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
|
||||
"""
|
||||
|
||||
|
||||
@@ -334,10 +373,11 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
|
||||
return signature_base64
|
||||
|
||||
def build_exported_megolm_key() -> dict:
|
||||
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
|
||||
"""
|
||||
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
|
||||
that can be imported via importRoomKeys API.
|
||||
Returns the exported key, the matching privat edKey (needed to encrypt)
|
||||
"""
|
||||
index = 0
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32))
|
||||
@@ -353,8 +393,10 @@ def build_exported_megolm_key() -> dict:
|
||||
|
||||
megolm_export = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": encode_base64(
|
||||
device_curve_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
),
|
||||
"session_id": encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
),
|
||||
@@ -365,8 +407,248 @@ def build_exported_megolm_key() -> dict:
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
}
|
||||
|
||||
return megolm_export, private_key
|
||||
|
||||
def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed25519.Ed25519PrivateKey) -> dict:
|
||||
|
||||
"""
|
||||
Very simple ratchet step from 0 to 1
|
||||
Used to generate a ratcheted key to test unknown message index.
|
||||
"""
|
||||
session_key: str = previous["session_key"]
|
||||
|
||||
# Get the megolm R0 from the export format
|
||||
decoded = base64.b64decode(session_key.encode("ascii"))
|
||||
ri = decoded[5:133]
|
||||
|
||||
ri0 = ri[0:32]
|
||||
ri1 = ri[32:64]
|
||||
ri2 = ri[64:96]
|
||||
ri3 = ri[96:128]
|
||||
|
||||
h = hmac.HMAC(ri3, hashes.SHA256())
|
||||
h.update(b'x\03')
|
||||
ri1_3 = h.finalize()
|
||||
|
||||
index = 1
|
||||
private_key = megolm_private_key
|
||||
|
||||
# exported key, start with version byte
|
||||
exported_key = bytearray(b'\x01')
|
||||
exported_key += index.to_bytes(4, 'big')
|
||||
exported_key += ri0
|
||||
exported_key += ri1
|
||||
exported_key += ri2
|
||||
exported_key += ri1_3
|
||||
# KPub
|
||||
exported_key += private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
|
||||
|
||||
megolm_export = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": previous["sender_key"],
|
||||
"session_id": previous["session_id"],
|
||||
"session_key": encode_base64(exported_key),
|
||||
"sender_claimed_keys": previous["sender_claimed_keys"],
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
}
|
||||
|
||||
return megolm_export
|
||||
|
||||
def encrypt_megolm_key_for_backup(session_data: dict, backup_public_key: x25519.X25519PublicKey) -> dict:
|
||||
|
||||
"""
|
||||
Encrypts an exported megolm key for key backup, using the m.megolm_backup.v1.curve25519-aes-sha2 algorithm.
|
||||
"""
|
||||
data = encode_canonical_json(session_data)
|
||||
|
||||
# Generate an ephemeral curve25519 key, and perform an ECDH with the ephemeral key
|
||||
# and the backup’s public key to generate a shared secret.
|
||||
# The public half of the ephemeral key, encoded using unpadded base64,
|
||||
# becomes the ephemeral property of the session_data.
|
||||
ephemeral_keypair = x25519.X25519PrivateKey.from_private_bytes(randbytes(32))
|
||||
shared_secret = ephemeral_keypair.exchange(backup_public_key)
|
||||
ephemeral = encode_base64(ephemeral_keypair.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))
|
||||
|
||||
# Using the shared secret, generate 80 bytes by performing an HKDF using SHA-256 as the hash,
|
||||
# with a salt of 32 bytes of 0, and with the empty string as the info.
|
||||
# The first 32 bytes are used as the AES key, the next 32 bytes are used as the MAC key,
|
||||
# and the last 16 bytes are used as the AES initialization vector.
|
||||
salt = bytes(32)
|
||||
info = b""
|
||||
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=80,
|
||||
salt=salt,
|
||||
info=info,
|
||||
)
|
||||
|
||||
raw_key = hkdf.derive(shared_secret)
|
||||
aes_key = raw_key[:32]
|
||||
mac = raw_key[32:64]
|
||||
iv = raw_key[64:80]
|
||||
|
||||
# Stringify the JSON object, and encrypt it using AES-CBC-256 with PKCS#7 padding.
|
||||
# This encrypted data, encoded using unpadded base64, becomes the ciphertext property of the session_data.
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
||||
encryptor = cipher.encryptor()
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded_data = padder.update(data) + padder.finalize()
|
||||
ct = encryptor.update(padded_data) + encryptor.finalize()
|
||||
cipher_text = encode_base64(ct)
|
||||
|
||||
# Pass the raw encrypted data (prior to base64 encoding) through HMAC-SHA-256 using the MAC key generated above.
|
||||
# The first 8 bytes of the resulting MAC are base64-encoded, and become the mac property of the session_data.
|
||||
h = hmac.HMAC(mac, hashes.SHA256())
|
||||
# h.update(ct)
|
||||
signature = h.finalize()
|
||||
mac = encode_base64(signature[:8])
|
||||
|
||||
encrypted_key = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": False,
|
||||
"session_data": {
|
||||
"ciphertext": cipher_text,
|
||||
"ephemeral": ephemeral,
|
||||
"mac": mac
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return encrypted_key
|
||||
|
||||
def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519PrivateKey, curve_key: x25519.X25519PrivateKey) -> tuple[dict, dict]:
|
||||
"""
|
||||
Encrypts an event using the given key in session export format.
|
||||
Will not do any ratcheting, just encrypt at index 0.
|
||||
"""
|
||||
|
||||
clear_event = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
}
|
||||
|
||||
session_key: str = exported_key["session_key"]
|
||||
|
||||
# Get the megolm R0 from the export format
|
||||
decoded = base64.b64decode(session_key.encode("ascii"))
|
||||
r0 = decoded[5:133]
|
||||
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=80,
|
||||
salt=bytes(32),
|
||||
info=b"MEGOLM_KEYS",
|
||||
)
|
||||
|
||||
raw_key = hkdf.derive(r0)
|
||||
aes_key = raw_key[:32]
|
||||
mac = raw_key[32:64]
|
||||
aes_iv = raw_key[64:80]
|
||||
|
||||
payload_json = {
|
||||
"room_id": clear_event["room_id"],
|
||||
"type": clear_event["type"],
|
||||
"content": clear_event["content"]
|
||||
}
|
||||
|
||||
payload_string = encode_canonical_json(payload_json)
|
||||
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(aes_iv))
|
||||
encryptor = cipher.encryptor()
|
||||
padder = padding.PKCS7(128).padder()
|
||||
|
||||
padded_data = padder.update(payload_string)
|
||||
padded_data += padder.finalize()
|
||||
|
||||
ct = encryptor.update(padded_data) + encryptor.finalize()
|
||||
|
||||
# The ratchet index i, and the cipher-text, are then packed
|
||||
# into a message as described in Message format. Then the entire message
|
||||
# (including the version bytes and all payload bytes) are passed through
|
||||
# HMAC-SHA-256. The first 8 bytes of the MAC are appended to the message.
|
||||
message = bytearray()
|
||||
message += b'\x03'
|
||||
# int tag for index
|
||||
message += b'\x08'
|
||||
# index is 0
|
||||
message += b'\x00'
|
||||
message += b'\x12'
|
||||
# probably works only for short messages
|
||||
message += len(ct).to_bytes(1, 'big')
|
||||
# encrypted data
|
||||
message += ct
|
||||
|
||||
h = hmac.HMAC(mac, hashes.SHA256())
|
||||
h.update(message)
|
||||
signature = h.finalize()
|
||||
mac = signature[:8]
|
||||
|
||||
message += mac
|
||||
|
||||
# Finally, the authenticated message is signed using the Ed25519 keypair;
|
||||
# the 64 byte signature is appended to the message
|
||||
signature = ed_key.sign(bytes(message))
|
||||
|
||||
message += signature
|
||||
|
||||
cipher_text = encode_base64(message)
|
||||
|
||||
encrypted_payload = {
|
||||
"algorithm" : "m.megolm.v1.aes-sha2",
|
||||
"sender_key" : encode_base64(curve_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
|
||||
"ciphertext" : cipher_text,
|
||||
"session_id" : exported_key["session_id"],
|
||||
"device_id" : "TEST_DEVICE"
|
||||
}
|
||||
|
||||
encrypted_event = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": encrypted_payload,
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000,
|
||||
}
|
||||
|
||||
return clear_event, encrypted_event
|
||||
|
||||
|
||||
def export_recovery_key(key_b64: str) -> str:
|
||||
"""
|
||||
Export a private recovery key as a recovery key that can be presented to users.
|
||||
As per spec https://spec.matrix.org/v1.8/client-server-api/#recovery-key
|
||||
"""
|
||||
private_key_bytes = base64.b64decode(key_b64)
|
||||
|
||||
# The 256-bit curve25519 private key is prepended by the bytes 0x8B and 0x01
|
||||
export_bytes = bytearray()
|
||||
export_bytes += b'\x8b'
|
||||
export_bytes += b'\x01'
|
||||
|
||||
export_bytes += private_key_bytes
|
||||
|
||||
# All the bytes in the string above, including the two header bytes,
|
||||
# are XORed together to form a parity byte. This parity byte is appended to the byte string.
|
||||
parity_byte = 0 #b'\x8b' ^ b'\x01'
|
||||
[parity_byte := parity_byte ^ x for x in export_bytes]
|
||||
|
||||
export_bytes += parity_byte.to_bytes(1, 'big')
|
||||
|
||||
# The byte string is encoded using base58
|
||||
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
|
||||
|
||||
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
|
||||
return ' '.join(split)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult } from "../../../src";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { IDownloadKeyResult, IEvent } from "../../../src";
|
||||
import { KeyBackupSession, KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -102,9 +102,28 @@ export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
}
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const ONE_TIME_KEYS = {
|
||||
"@alice:localhost": {
|
||||
"test_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "25djC6Rk6gIgFBMVawY9X9LnY8XMMziey6lKqL8Q5Bbp7T1vw9uk0RE7eKO2a/jNLcYroO2xRztGhBrKz5sOCQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
@@ -123,8 +142,8 @@ export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"room_id": "!room:id",
|
||||
"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",
|
||||
"sender_claimed_keys": {
|
||||
@@ -134,8 +153,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "mPYSGA2l1tOQiipEDEVYhDSdTSFh2lDW1qpGKYZRxTc",
|
||||
"session_key": "AQAAAAAHwgkB49BTPAEGTCK6degxUIbl8GPG2ugPRYhNtOpNic63u11+baXFfjDw5fmVfD1gJXpQQjGsqrIYioxrB1xzl7mfb942UHhYdaMQZowpp1fSpJVsxR5TddUU2EWifYD9EQsoz8mY1zqoazm4vUP4v9yxaTcUBj2c6HMJCY0gCJj2EhgNpdbTkIoqRAxFWIQ0nU0hYdpQ1taqRimGUcU3",
|
||||
"sender_claimed_keys": {
|
||||
@@ -148,8 +167,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
/** An exported megolm session */
|
||||
export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"session_key": "AQAAAABXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIXBlK8tAfJo3cJnlh7F4ltEOAqrdME6dU0zXTkqXmURqYqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
|
||||
"sender_claimed_keys": {
|
||||
@@ -158,22 +177,58 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const ONE_TIME_KEYS = {
|
||||
"@alice:localhost": {
|
||||
"test_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "25djC6Rk6gIgFBMVawY9X9LnY8XMMziey6lKqL8Q5Bbp7T1vw9uk0RE7eKO2a/jNLcYroO2xRztGhBrKz5sOCQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** A ratcheted version of MEGOLM_SESSION_DATA */
|
||||
export const RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"session_key": "AQAAAAFXGO+Z9jlQJhIL6ByhXrv2BwCIxkhh7MXpKLsYmXkJcWrQlirmXmD79ga1zo+I4DCtEZzyGSpDWXBC6G7ez3H4gDMBam1RE3Jm5tc+oTlIri32UkYgSL0kBkcEnttqmIUWvpwC7by/yg231+gyzu9lDHAU4ivCj48pt7WGiORWmIqXSOmbP+w8xUxIYgNohmjA3x5CGApXql0+i/uXtf3K",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** The key from MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
|
||||
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
|
||||
"mac": "OibmACbORhI"
|
||||
}
|
||||
};
|
||||
|
||||
/** A test clear event */
|
||||
export const CLEAR_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"ciphertext": "AwgAEnAkBmciEAyhh1j6DCk29UXJ7kv/kvayUNfuNT0iAioLxcXjFXOZ5ho3jF1/wrytlt0Lb298uMM67OxdVMi+/mMfYpwlvi07P9cIH6CMSj8tyhYoWl0SrKY6tkPf5GWOlRSRRKbziXa96FHXvnA3V2FCAIGtAe3G4ei5RPbhkmKAFBLAen33/D6MjJVqU8Ojr5vTkgls5eyirarlVpsmnH06alDaxO8avrU0NL+Vsw26xvlUQgEMOnUJ",
|
||||
"session_id": "ipdI6Zs/7DzFTEhiA2iGaMDfHkIYCleqXT6L+5e1/co",
|
||||
"device_id": "TEST_DEVICE"
|
||||
},
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
// Bob data
|
||||
|
||||
export const BOB_TEST_USER_ID = "@bob:xyz";
|
||||
@@ -267,9 +322,28 @@ export const BOB_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
}
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const BOB_ONE_TIME_KEYS = {
|
||||
"@bob:xyz": {
|
||||
"bob_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "dlZc9VA/hP980Mxvu9qwi0qJx8VK7sADGOM48CE01YM7K/Mbty9lis/QjtQAWqDg371QyynVRjEzt9qj7eSFCg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
@@ -288,23 +362,23 @@ export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "X9SK5JceUdvUr9gZkmdfS78qmKztoL60oORJ9JA5XD8",
|
||||
"session_key": "AQAAAADbfOdUj/ec5bK4xVEhhRw+jfd1FD4uA0MLy7NyVHugZxyXNZUx5YEof4H9YyjBretviZreMSXqflbgYKz257rkKu7MPeKFf7zmln2GxX0F/p++GOnvpY1FqOglhfRQi3tqiyOa7SL4f7TuERDTOpMqlWhIfTKQnqy0AyF2vpDi5V/UiuSXHlHb1K/YGZJnX0u/Kpis7aC+tKDkSfSQOVw/",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "/2K+V777vipCxPZ0gpY9qcpz1DYaXwuMRIu0UEP0Wa0",
|
||||
"session_key": "AQAAAAAclzWVMeWBKH+B/WMowa3rb4ma3jEl6n5W4GCs9ue65CruzD3ihX+85pZ9hsV9Bf6fvhjp76WNRajoJYX0UIt7aosjmu0i+H+07hEQ0zqTKpVoSH0ykJ6stAMhdr6Q4uW5crBmdTTBIsqmoWsNJZKKoE2+ldYrZ1lrFeaJbjBIY/9ivle++74qQsT2dIKWPanKc9Q2Gl8LjESLtFBD9Fmt",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "ZG6lrfATe+958wN1xaGf3dKG/CThEfkmNdp1jcu4zok"
|
||||
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA",
|
||||
"session_key": "AQAAAACv0khqPrQ91MmWCgm0RTzfpn65AGCrRnAKLxGJdfSfECNZ8gyj34FZLwi+F+xC6ibFddcbLXW0mzR6PnTnHF3VHM4g/h+2rcxtlix8fySpIwFzaXViba7cOSy/b+dHTMZB40iA7F4y7AdTdHLv4N1XUj3puU/KVUIKf9/lEDLqyReD+39WdEY24mTIB5NcQQhtyguPzYPT5sSyeIUNd7Bw",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "+07YOpSgdZ1X9le3n3NMByw0V1B0H0Djnbm76jgmWoo",
|
||||
"session_key": "AQAAAAAjWfIMo9+BWS8IvhfsQuomxXXXGy11tJs0ej505xxd1RzOIP4ftq3MbZYsfH8kqSMBc2l1Ym2u3Dksv2/nR0zGQeNIgOxeMuwHU3Ry7+DdV1I96blPylVCCn/f5RAy6smKoaeylptPdXgVXmw3YBBUVYpHpm+xCIUUp9foAdb8hftO2DqUoHWdV/ZXt59zTAcsNFdQdB9A4525u+o4JlqK",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "HxUKnGfeUu0fF3cLyCFSDXYtVCQHy/+33q9RkzKfsiU"
|
||||
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
}
|
||||
@@ -313,29 +387,65 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
/** An exported megolm session */
|
||||
export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!roomA:example.org",
|
||||
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
|
||||
"session_id": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM",
|
||||
"session_key": "AQAAAACvcoGk7mOY59fOqZaxFUiTCBRV1Ia94KBjAZx6kgdgBtkkvs50z8od8/Nc9ncK2UsEiXNvCTTp2dlN3du+Rx0/m7vet2ZOEEp2oYDjHMLLFmwd1gtlGuWYPdXA6Y1+9Yyph0/EDVfS+zd3XvbL0QgbyL43+yQnFNHKlxVJX1eiKTrGTHQtYEOZz6/i/bbk+sV7GhSZFT5IMT9hXsRxdf0D",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"session_key": "AQAAAADZJL7OdM/KHfPzXPZ3CtlLBIlzbwk06dnZTd3bvkcdP5u73rdmThBKdqGA4xzCyxZsHdYLZRrlmD3VwOmNfvWMqYdPxA1X0vs3d172y9EIG8i+N/skJxTRypcVSV9XoinBNIWr/gkyepuAKiQqemlc8J5amD9OkmbVkmnrxP1uyYMsMnQayCXCVpLQv4nN7bpymTNV2ZtTKWImLXW4/EaX",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "dV0TIhhkToXpL+gZLo+zXDHJfw7MWYxpg80cynIQDv0"
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const BOB_ONE_TIME_KEYS = {
|
||||
"@bob:xyz": {
|
||||
"bob_device": {
|
||||
"signed_curve25519:AAAAHQ": {
|
||||
"key": "j3fR3HemM16M7CWhoI4Sk5ZsdmdfQHsKL1xuSft6MSw",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "dlZc9VA/hP980Mxvu9qwi0qJx8VK7sADGOM48CE01YM7K/Mbty9lis/QjtQAWqDg371QyynVRjEzt9qj7eSFCg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
|
||||
export const BOB_RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"room_id": "!room:id",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"session_key": "AQAAAAHZJL7OdM/KHfPzXPZ3CtlLBIlzbwk06dnZTd3bvkcdP5u73rdmThBKdqGA4xzCyxZsHdYLZRrlmD3VwOmNfvWMqYdPxA1X0vs3d172y9EIG8i+N/skJxTRypcVSV9Xoil2JdGx9oPqR0dFVh661Aqs86rJRbQ4IeRiuEm35VMxboMsMnQayCXCVpLQv4nN7bpymTNV2ZtTKWImLXW4/EaX",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
};
|
||||
|
||||
/** The key from BOB_MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
|
||||
export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"first_message_index": 1,
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
|
||||
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
|
||||
"mac": "lEfHlqfJQwU"
|
||||
}
|
||||
};
|
||||
|
||||
/** A test clear event */
|
||||
export const BOB_CLEAR_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.message",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": "Hello world"
|
||||
}
|
||||
};
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"type": "m.room.encrypted",
|
||||
"room_id": "!room:id",
|
||||
"sender": "@alice:localhost",
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"sender_key": "FOvlmz18LLI3k/llCpqRoKT90+gFF8YhuL+v1YBXHlw",
|
||||
"ciphertext": "AwgAEnA/mEqZm2lSrhoG11OpDqsohGSBJWsudbuoItLlivmpFZQHrKMbE6z/dhCTwUi76vwfRCtf4tyPMD845cqZH1nL0bowq3/awyzZ8Q263Y3WrLfkUTFBU6oPF/IULUFZZuw6kLdfd5g5+uigvqUhFFpICoj7KNHznv4sFNssd00/WgJquZ6PRt6e1v6ANFNiZPAwghIL+kBc6pb8i6MUWt9JnXilJhTqFDHdXiY4qkaKBWbwebC26PYM",
|
||||
"session_id": "gywydBrIJcJWktC/ic3tunKZM1XZm1MpYiYtdbj8Rpc",
|
||||
"device_id": "TEST_DEVICE"
|
||||
},
|
||||
"event_id": "$event1",
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
|
||||
join: { [roomId]: roomResponse },
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
@@ -520,10 +521,19 @@ export async function awaitDecryption(
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
if (waitOnDecryptionFailure) {
|
||||
event.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
if (!err) {
|
||||
resolve(ev);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +485,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
|
||||
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
|
||||
public getRoom = jest.fn();
|
||||
public getFoci = jest.fn();
|
||||
|
||||
public supportsThreads(): boolean {
|
||||
return true;
|
||||
|
||||
+126
-2
@@ -15,11 +15,16 @@ import { sleep } from "../../src/utils";
|
||||
import { CRYPTO_ENABLED } from "../../src/client";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { logger } from "../../src/logger";
|
||||
import { MemoryStore } from "../../src";
|
||||
import { DeviceVerification, MemoryStore } from "../../src";
|
||||
import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager";
|
||||
import { RoomMember } from "../../src/models/room-member";
|
||||
import { IStore } from "../../src/store";
|
||||
import { IRoomEncryption, RoomList } from "../../src/crypto/RoomList";
|
||||
import { EventShieldColour, EventShieldReason } from "../../src/crypto-api";
|
||||
import { UserTrustLevel } from "../../src/crypto/CrossSigning";
|
||||
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
|
||||
import { EventDecryptionResult } from "../../src/common-crypto/CryptoBackend";
|
||||
import * as testData from "../test-utils/test-data";
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
@@ -111,13 +116,14 @@ describe("Crypto", function () {
|
||||
});
|
||||
|
||||
describe("encrypted events", function () {
|
||||
it("provides encryption information", async function () {
|
||||
it("provides encryption information for events from unverified senders", async function () {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
// unencrypted event
|
||||
const event = {
|
||||
getId: () => "$event_id",
|
||||
getSender: () => "@bob:example.com",
|
||||
getSenderKey: () => null,
|
||||
getWireContent: () => {
|
||||
return {};
|
||||
@@ -127,6 +133,8 @@ describe("Crypto", function () {
|
||||
let encryptionInfo = client.getEventEncryptionInfo(event);
|
||||
expect(encryptionInfo.encrypted).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null);
|
||||
|
||||
// unknown sender (e.g. deleted device), forwarded megolm key (untrusted)
|
||||
event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI";
|
||||
event.getWireContent = () => {
|
||||
@@ -141,6 +149,11 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.authenticated).toBeFalsy();
|
||||
expect(encryptionInfo.sender).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
|
||||
// known sender, megolm key from backup
|
||||
event.getForwardingCurve25519KeyChain = () => [];
|
||||
event.isKeySourceUntrusted = () => true;
|
||||
@@ -155,6 +168,11 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.sender).toBeTruthy();
|
||||
expect(encryptionInfo.mismatchedSender).toBeFalsy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
|
||||
// known sender, trusted megolm key, but bad ed25519key
|
||||
event.isKeySourceUntrusted = () => false;
|
||||
device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
@@ -165,9 +183,115 @@ describe("Crypto", function () {
|
||||
expect(encryptionInfo.sender).toBeTruthy();
|
||||
expect(encryptionInfo.mismatchedSender).toBeTruthy();
|
||||
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY,
|
||||
});
|
||||
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
describe("provides encryption information for events from verified senders", function () {
|
||||
const testDeviceId = testData.BOB_TEST_DEVICE_ID;
|
||||
const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA;
|
||||
|
||||
let client: MatrixClient;
|
||||
beforeEach(async () => {
|
||||
client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
// mock out the verification check
|
||||
client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
async function buildEncryptedEvent(
|
||||
decryptionResult: Partial<EventDecryptionResult> = {},
|
||||
): Promise<MatrixEvent> {
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: async (event: MatrixEvent): Promise<EventDecryptionResult> => {
|
||||
return {
|
||||
claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId],
|
||||
clearEvent: {
|
||||
room_id: "!room_id",
|
||||
type: "m.room.message",
|
||||
content: { body: "test" },
|
||||
},
|
||||
forwardingCurve25519KeyChain: [],
|
||||
senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId],
|
||||
...decryptionResult,
|
||||
};
|
||||
},
|
||||
} as unknown as CryptoBackend;
|
||||
|
||||
const event = new MatrixEvent({
|
||||
event_id: "$event_id",
|
||||
sender: testData.BOB_TEST_USER_ID,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
});
|
||||
await event.attemptDecryption(mockCryptoBackend);
|
||||
return event;
|
||||
}
|
||||
|
||||
it("unknown device", async () => {
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
});
|
||||
});
|
||||
|
||||
it("known but unsigned device", async () => {
|
||||
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
|
||||
[testDeviceId]: {
|
||||
keys: testDevice.keys,
|
||||
algorithms: testDevice.algorithms,
|
||||
verified: DeviceVerification.Unverified,
|
||||
known: true,
|
||||
},
|
||||
});
|
||||
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNVERIFIED_IDENTITY,
|
||||
});
|
||||
});
|
||||
|
||||
describe("known and verified device", () => {
|
||||
beforeEach(() => {
|
||||
client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, {
|
||||
[testDeviceId]: {
|
||||
keys: testDevice.keys,
|
||||
algorithms: testDevice.algorithms,
|
||||
verified: DeviceVerification.Verified,
|
||||
known: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("regular key", async () => {
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("unauthenticated key", async () => {
|
||||
const event = await buildEncryptedEvent({ untrusted: true });
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../../src/common-crypto/base64";
|
||||
|
||||
describe("Crypto Base64 encoding", () => {
|
||||
it("Should decode properly encoded data", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const encoded = encodeBase64(new TextEncoder().encode(toEncode));
|
||||
const decoded = new TextDecoder().decode(decodeBase64(encoded));
|
||||
|
||||
expect(decoded).toStrictEqual(toEncode);
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const paddedEncoded = encodeBase64(data);
|
||||
const unpaddedEncoded = encodeUnpaddedBase64(data);
|
||||
|
||||
expect(paddedEncoded).not.toEqual(unpaddedEncoded);
|
||||
|
||||
const padding = paddedEncoded.charAt(paddedEncoded.length - 1);
|
||||
expect(padding).toStrictEqual("=");
|
||||
});
|
||||
|
||||
it("Decode should be indifferent to padding", async () => {
|
||||
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
|
||||
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";
|
||||
|
||||
const decodedPad = decodeBase64(withPadding);
|
||||
const decodedNoPad = decodeBase64(withoutPadding);
|
||||
|
||||
expect(decodedPad).toStrictEqual(decodedNoPad);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,14 @@ limitations under the License.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
import { MockedObject } from "jest-mock";
|
||||
import { WidgetApi, WidgetApiToWidgetAction, MatrixCapabilities, ITurnServer, IRoomEvent } from "matrix-widget-api";
|
||||
import {
|
||||
WidgetApi,
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
ITurnServer,
|
||||
IRoomEvent,
|
||||
IOpenIDCredentials,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { createRoomWidgetClient, MsgType } from "../../src/matrix";
|
||||
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client";
|
||||
@@ -33,6 +40,12 @@ import { MatrixEvent } from "../../src/models/event";
|
||||
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
|
||||
const testOIDCToken = {
|
||||
access_token: "12345678",
|
||||
expires_in: "10",
|
||||
matrix_server_name: "homeserver.oabc",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn();
|
||||
public requestCapability = jest.fn();
|
||||
@@ -49,8 +62,15 @@ class MockWidgetApi extends EventEmitter {
|
||||
public sendRoomEvent = jest.fn(() => ({ event_id: `$${Math.random()}` }));
|
||||
public sendStateEvent = jest.fn();
|
||||
public sendToDevice = jest.fn();
|
||||
public requestOpenIDConnectToken = jest.fn(() => {
|
||||
return testOIDCToken;
|
||||
return new Promise<IOpenIDCredentials>(() => {
|
||||
return testOIDCToken;
|
||||
});
|
||||
});
|
||||
public readStateEvents = jest.fn(() => []);
|
||||
public getTurnServers = jest.fn(() => []);
|
||||
public sendContentLoaded = jest.fn();
|
||||
|
||||
public transport = { reply: jest.fn() };
|
||||
}
|
||||
@@ -285,7 +305,12 @@ describe("RoomWidgetClient", () => {
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oidc token", () => {
|
||||
it("requests an oidc token", async () => {
|
||||
await makeClient({});
|
||||
expect(await client.getOpenIdToken()).toStrictEqual(testOIDCToken);
|
||||
});
|
||||
});
|
||||
it("gets TURN servers", async () => {
|
||||
const server1: ITurnServer = {
|
||||
uris: [
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("InteractiveAuth", () => {
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should fire stateUpdated callback if with error when encountered", async () => {
|
||||
it("should fire stateUpdated callback with error when a request fails", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
@@ -578,15 +578,20 @@ describe("InteractiveAuth", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
// StateUpdated should be called. We call submitAuthDict() to trigger a request ...
|
||||
let firstTime = true;
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
// Only trigger the request the first time, to avoid an infinite loop
|
||||
if (firstTime) {
|
||||
firstTime = false;
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
// .. which which we then reject, so we can test the behaviour in that case.
|
||||
doRequest.mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN", error: "This is an error" }));
|
||||
|
||||
await Promise.allSettled([ia.attemptAuth()]);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "../../../src";
|
||||
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
};
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
sender: {
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
it("rejects membership with no expiry", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no device_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no call_id", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects membership with no scope", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
expect(membership.createdTs()).toEqual(12345);
|
||||
});
|
||||
|
||||
it("uses created_ts if present", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(12345),
|
||||
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
|
||||
);
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(6000);
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns active foci", () => {
|
||||
const fakeEvent = makeMockEvent();
|
||||
const mockFocus = { type: "this_is_a_mock_focus" };
|
||||
const membership = new CallMembership(
|
||||
fakeEvent,
|
||||
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
|
||||
);
|
||||
expect(membership.getActiveFoci()).toEqual([mockFocus]);
|
||||
});
|
||||
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
// our clock would have been at 2000 at the creation time (our clock at event receive time - age)
|
||||
// (ie. the local clock is 1 second ahead of the servers' clocks)
|
||||
fakeEvent.localTimestamp = 2000;
|
||||
|
||||
// for simplicity's sake, we say that the event's age is zero
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(0);
|
||||
|
||||
membership = new CallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("converts expiry time into local clock", () => {
|
||||
// for sanity's sake, make sure the server-relative expiry time is what we expect
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(6000);
|
||||
// therefore the expiry time converted to our clock should be 1 second later
|
||||
expect(membership.getLocalExpiry()).toEqual(7000);
|
||||
});
|
||||
|
||||
it("calculates time until expiry", () => {
|
||||
jest.setSystemTime(2000);
|
||||
expect(membership.getMsUntilExpiry()).toEqual(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, mockRTCEvent } from "./mocks";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
|
||||
describe("MatrixRTCSession", () => {
|
||||
let client: MatrixClient;
|
||||
let sess: MatrixRTCSession | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.getUserId = jest.fn().mockReturnValue("@alice:example.org");
|
||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
if (sess) sess.stop();
|
||||
sess = undefined;
|
||||
});
|
||||
|
||||
it("Creates a room-scoped session from room state", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].callId).toEqual("");
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], () => 10000);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
});
|
||||
|
||||
it("honours created_ts", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.created_ts = 500;
|
||||
expiredMembership.expires = 1000;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
|
||||
});
|
||||
|
||||
it("returns empty session if no membership events are present", () => {
|
||||
const mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with no memberships section", () => {
|
||||
const mockRoom = {
|
||||
roomId: randomString(8),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("safely ignores events with junk memberships section", () => {
|
||||
const mockRoom = {
|
||||
roomId: randomString(8),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue({
|
||||
getStateEvents: (_type: string, _stateKey: string) => [
|
||||
{
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: jest.fn().mockReturnValue(0),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom as unknown as Room);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no expires_ts", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
(expiredMembership.expires as number | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([expiredMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no device_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.device_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
const sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no call_id", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.call_id as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores memberships with no scope", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
(testMembership.scope as string | undefined) = undefined;
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores anything that's not a room-scoped call (for now)", () => {
|
||||
const testMembership = Object.assign({}, membershipTemplate);
|
||||
testMembership.scope = "m.user";
|
||||
const mockRoom = makeMockRoom([testMembership]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("getOldestMembership", () => {
|
||||
it("returns the oldest membership event", () => {
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
||||
]);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
||||
});
|
||||
});
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// stop the timers
|
||||
sess!.leaveRoomSession();
|
||||
});
|
||||
|
||||
it("starts un-joined", () => {
|
||||
expect(sess!.isJoined()).toEqual(false);
|
||||
});
|
||||
|
||||
it("shows joined once join is called", () => {
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
expect(sess!.isJoined()).toEqual(true);
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [{ type: "mock" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", () => {
|
||||
const sendStateEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renews membership event before expiry time", async () => {
|
||||
jest.useFakeTimers();
|
||||
let resolveFn: ((_roomId: string, _type: string, val: Record<string, any>) => void) | undefined;
|
||||
|
||||
const eventSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
r(val);
|
||||
};
|
||||
});
|
||||
try {
|
||||
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
const eventContent = await eventSentPromise;
|
||||
|
||||
// definitely should have renewed by 1 second before the expiry!
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, () => timeElapsed));
|
||||
|
||||
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
r(val);
|
||||
};
|
||||
});
|
||||
|
||||
sendStateEventMock.mockReset().mockImplementation(resolveFn);
|
||||
|
||||
jest.setSystemTime(Date.now() + timeElapsed);
|
||||
jest.advanceTimersByTime(timeElapsed);
|
||||
await eventReSentPromise;
|
||||
|
||||
expect(sendStateEventMock).toHaveBeenCalledWith(
|
||||
mockRoom.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000 * 2,
|
||||
foci_active: [{ type: "mock" }],
|
||||
created_ts: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an event at the time a membership event expires", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
let eventAge = 0;
|
||||
|
||||
const membership = Object.assign({}, membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membership], () => eventAge);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const membershipObject = sess.memberships[0];
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
eventAge = 61 * 1000 * 1000;
|
||||
jest.advanceTimersByTime(61 * 1000 * 1000);
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []);
|
||||
expect(sess?.memberships.length).toEqual(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prunes expired memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
let eventAge = 0;
|
||||
|
||||
const mockRoom = makeMockRoom(
|
||||
[
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 1000,
|
||||
}),
|
||||
],
|
||||
() => eventAge,
|
||||
);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
// sanity check
|
||||
expect(sess.memberships).toHaveLength(1);
|
||||
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
|
||||
|
||||
eventAge = 10000;
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills in created_ts for other memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
}),
|
||||
]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 3600000,
|
||||
created_ts: 1000,
|
||||
},
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom } from "./mocks";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
|
||||
const memberships = [membershipTemplate];
|
||||
|
||||
const room1 = makeMockRoom(memberships);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
memberships.splice(0, 1);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("")[0];
|
||||
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
|
||||
export function makeMockRoom(
|
||||
memberships: CallMembershipData[],
|
||||
getLocalAge: (() => number) | undefined = undefined,
|
||||
): Room {
|
||||
const roomId = randomString(8);
|
||||
return {
|
||||
roomId: roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(makeMockRoomState(memberships, roomId, getLocalAge)),
|
||||
}),
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
|
||||
return {
|
||||
getStateEvents: (_: string, stateKey: string) => {
|
||||
const event = mockRTCEvent(memberships, roomId, getLocalAge);
|
||||
|
||||
if (stateKey !== undefined) return event;
|
||||
return [event];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(
|
||||
memberships: CallMembershipData[],
|
||||
roomId: string,
|
||||
getLocalAge: (() => number) | undefined,
|
||||
): MatrixEvent {
|
||||
const getLocalAgeFn = getLocalAge ?? (() => 10);
|
||||
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
memberships: memberships,
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: getLocalAgeFn,
|
||||
localTimestamp: Date.now(),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
@@ -1453,6 +1453,87 @@ describe("Room", function () {
|
||||
}
|
||||
|
||||
describe("addReceipt", function () {
|
||||
describe("resets the unread count", () => {
|
||||
const event1 = utils.mkMessage({ room: roomId, user: userA, msg: "1", event: true });
|
||||
const event2 = utils.mkMessage({ room: roomId, user: userA, msg: "2", event: true });
|
||||
|
||||
it("should reset the unread count when our non-synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for me for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is set to 0
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(0);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when someone else's receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for someone else for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userB, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is unchanged because it's not my receipt
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when our non-synthetic receipt points to an earlier event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a receipt for me for an earlier event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event1.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then the count is unchanged because it wasn't the latest event
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
|
||||
it("should not reset the unread count when our a synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
|
||||
// When I receive a synthetic receipt for me for the last event
|
||||
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
|
||||
room.addReceipt(receipt, true);
|
||||
|
||||
// Then the count is unchanged because the receipt was synthetic
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
|
||||
});
|
||||
});
|
||||
|
||||
it("should store the receipt so it can be obtained via getReceiptsForEvent", function () {
|
||||
const ts = 13787898424;
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("CrossSigningIdentity", () => {
|
||||
get: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage, jest.fn());
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { Mocked } from "jest-mock";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IHttpOpts,
|
||||
IToDeviceEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixHttpApi,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
@@ -38,8 +39,15 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { CryptoCallbacks, ImportRoomKeysOpts, VerificationRequest } from "../../../src/crypto-api";
|
||||
import {
|
||||
CryptoCallbacks,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
VerificationRequest,
|
||||
} from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
@@ -274,6 +282,31 @@ describe("RustCrypto", () => {
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledWith(testReq);
|
||||
});
|
||||
|
||||
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
|
||||
// the first call to `outgoingMessages` will return a promise which blocks for a while
|
||||
const firstOutgoingRequestsDefer = defer<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
|
||||
|
||||
// the second will return a KeysQueryRequest.
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
outgoingRequestQueue.push([testReq]);
|
||||
|
||||
// the first sync completes, triggering the first call to `outgoingMessages`
|
||||
rustCrypto.onSyncCompleted({});
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a second /sync completes before the first call to `outgoingRequests` completes. It shouldn't trigger
|
||||
// a second call immediately, but should queue one up.
|
||||
rustCrypto.onSyncCompleted({});
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
|
||||
|
||||
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
|
||||
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
|
||||
firstOutgoingRequestsDefer.resolve([]);
|
||||
await awaitCallToMakeOutgoingRequest();
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops looping when stop() is called", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
|
||||
@@ -347,6 +380,111 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".getEncryptionInfoForEvent", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
beforeEach(() => {
|
||||
olmMachine = {
|
||||
getRoomEventEncryptionInfo: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function makeEncryptedEvent(): Promise<MatrixEvent> {
|
||||
const encryptedEvent = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "fake_alg" },
|
||||
room: "!room:id",
|
||||
});
|
||||
encryptedEvent.event.event_id = "$event:id";
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: () =>
|
||||
({
|
||||
clearEvent: { content: { body: "1234" } },
|
||||
} as unknown as IEventDecryptionResult),
|
||||
} as unknown as CryptoBackend;
|
||||
await encryptedEvent.attemptDecryption(mockCryptoBackend);
|
||||
return encryptedEvent;
|
||||
}
|
||||
|
||||
it("should handle unencrypted events", async () => {
|
||||
const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } });
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(event);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the event into the OlmMachine", async () => {
|
||||
const encryptedEvent = await makeEncryptedEvent();
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(encryptedEvent);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).toHaveBeenCalledTimes(1);
|
||||
const [passedEvent, passedRoom] = olmMachine.getRoomEventEncryptionInfo.mock.calls[0];
|
||||
expect(passedRoom.toString()).toEqual("!room:id");
|
||||
expect(JSON.parse(passedEvent)).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
event_id: "$event:id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RustSdkCryptoJs.ShieldColor.None, EventShieldColour.NONE],
|
||||
[RustSdkCryptoJs.ShieldColor.Grey, EventShieldColour.GREY],
|
||||
[RustSdkCryptoJs.ShieldColor.Red, EventShieldColour.RED],
|
||||
])("gets the right shield color (%i)", async (rustShield, expectedShield) => {
|
||||
const mockEncryptionInfo = {
|
||||
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: null }),
|
||||
} as unknown as RustSdkCryptoJs.EncryptionInfo;
|
||||
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(await makeEncryptedEvent());
|
||||
expect(mockEncryptionInfo.shieldState).toHaveBeenCalledWith(false);
|
||||
expect(res).not.toBe(null);
|
||||
expect(res!.shieldColour).toEqual(expectedShield);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
["Encrypted by an unverified user.", EventShieldReason.UNVERIFIED_IDENTITY],
|
||||
["Encrypted by a device not verified by its owner.", EventShieldReason.UNSIGNED_DEVICE],
|
||||
[
|
||||
"The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
],
|
||||
["Encrypted by an unknown or deleted device.", EventShieldReason.UNKNOWN_DEVICE],
|
||||
["bloop", EventShieldReason.UNKNOWN],
|
||||
])("gets the right shield reason (%s)", async (rustReason, expectedReason) => {
|
||||
// suppress the warning from the unknown shield reason
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const mockEncryptionInfo = {
|
||||
shieldState: jest
|
||||
.fn()
|
||||
.mockReturnValue({ color: RustSdkCryptoJs.ShieldColor.None, message: rustReason }),
|
||||
} as unknown as RustSdkCryptoJs.EncryptionInfo;
|
||||
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(await makeEncryptedEvent());
|
||||
expect(mockEncryptionInfo.shieldState).toHaveBeenCalledWith(false);
|
||||
expect(res).not.toBe(null);
|
||||
expect(res!.shieldReason).toEqual(expectedReason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get|setTrustCrossSignedDevices", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
|
||||
@@ -16,8 +16,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { IJoinedRoom, ISyncResponse, SyncAccumulator } from "../../src/sync-accumulator";
|
||||
import {
|
||||
IJoinedRoom,
|
||||
IInvitedRoom,
|
||||
IKnockedRoom,
|
||||
IKnockState,
|
||||
ILeftRoom,
|
||||
IRoomEvent,
|
||||
IStateEvent,
|
||||
IStrippedState,
|
||||
ISyncResponse,
|
||||
SyncAccumulator,
|
||||
IInviteState,
|
||||
} from "../../src/sync-accumulator";
|
||||
import { IRoomSummary } from "../../src";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
|
||||
// The event body & unsigned object get frozen to assert that they don't get altered
|
||||
// by the impl
|
||||
@@ -95,6 +108,13 @@ describe("SyncAccumulator", function () {
|
||||
},
|
||||
},
|
||||
},
|
||||
knock: {
|
||||
"!knock": {
|
||||
knock_state: {
|
||||
events: [member("alice", "knock")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
sa.accumulate(res);
|
||||
@@ -287,6 +307,268 @@ describe("SyncAccumulator", function () {
|
||||
expect(sa.getJSON().accountData[0]).toEqual(acc2);
|
||||
});
|
||||
|
||||
it("should delete invite room when invite request is rejected", () => {
|
||||
const initInviteState: IInviteState = {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{
|
||||
"!invite:bar": {
|
||||
invite_state: initInviteState,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.invite["!invite:bar"].invite_state).toBe(initInviteState);
|
||||
|
||||
const rejectMemberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "bob",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "invite",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([rejectMemberEvent]);
|
||||
|
||||
// bob rejects invite
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!invite:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.invite["!invite:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accumulate knock state", () => {
|
||||
const initKnockState = {
|
||||
events: [member("alice", "knock")],
|
||||
};
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room 1",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content
|
||||
.name,
|
||||
).toEqual("Room 1");
|
||||
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room 2",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content
|
||||
.name,
|
||||
).toEqual("Room 2");
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is approved", () => {
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// alice approves bob's knock request
|
||||
const inviteStateEvents = [
|
||||
{
|
||||
content: {
|
||||
membership: "invite",
|
||||
},
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
},
|
||||
];
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{
|
||||
"!knock:bar": {
|
||||
invite_state: {
|
||||
events: inviteStateEvents,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
expect(sa.getJSON().roomsData.invite["!knock:bar"].invite_state.events).toEqual(inviteStateEvents);
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is cancelled by user himself", () => {
|
||||
// bob cancels his knock state
|
||||
const memberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "bob",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([memberEvent]);
|
||||
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// bob cancels his knock request
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!knock:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should delete knocked room when knock request is denied by another user", () => {
|
||||
// alice denies bob knock state
|
||||
const memberEvent: IStateEvent = {
|
||||
event_id: "$" + Math.random(),
|
||||
content: {
|
||||
membership: "leave",
|
||||
},
|
||||
origin_server_ts: 123456789,
|
||||
state_key: "bob",
|
||||
sender: "alice",
|
||||
type: "m.room.member",
|
||||
unsigned: {
|
||||
prev_content: {
|
||||
membership: "knock",
|
||||
},
|
||||
},
|
||||
};
|
||||
const leftRoomState = leftRoomSkeleton([memberEvent]);
|
||||
|
||||
const initKnockState = makeKnockState();
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
knock_state: initKnockState,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
|
||||
|
||||
// alice denies bob's knock request
|
||||
sa.accumulate(
|
||||
syncSkeleton(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"!knock:bar": leftRoomState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accumulate read receipts", () => {
|
||||
const receipt1 = {
|
||||
type: "m.receipt",
|
||||
@@ -601,7 +883,12 @@ describe("SyncAccumulator", function () {
|
||||
});
|
||||
});
|
||||
|
||||
function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse {
|
||||
function syncSkeleton(
|
||||
joinObj: Partial<IJoinedRoom>,
|
||||
invite?: Record<string, IInvitedRoom>,
|
||||
leave?: Record<string, ILeftRoom>,
|
||||
knockObj?: Partial<IKnockedRoom>,
|
||||
): ISyncResponse {
|
||||
joinObj = joinObj || {};
|
||||
return {
|
||||
next_batch: "abc",
|
||||
@@ -609,10 +896,48 @@ function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse {
|
||||
join: {
|
||||
"!foo:bar": joinObj,
|
||||
},
|
||||
invite,
|
||||
leave,
|
||||
knock: knockObj
|
||||
? {
|
||||
"!knock:bar": knockObj,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
}
|
||||
|
||||
function leftRoomSkeleton(timelineEvents: Array<IRoomEvent | IStateEvent> = []): ILeftRoom {
|
||||
return {
|
||||
state: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: timelineEvents,
|
||||
prev_batch: "something",
|
||||
},
|
||||
account_data: {
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeKnockState(): IKnockState {
|
||||
return {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
user: "alice",
|
||||
room: "!knock:bar",
|
||||
type: "m.room.name",
|
||||
content: {
|
||||
name: "Room",
|
||||
},
|
||||
}) as IStrippedState,
|
||||
member("bob", "knock"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function msg(localpart: string, text: string) {
|
||||
return {
|
||||
event_id: "$" + Math.random(),
|
||||
|
||||
@@ -71,7 +71,8 @@ describe("Group Call Event Handler", function () {
|
||||
getMember: (userId: string) => (userId === FAKE_USER_ID ? mockMember : null),
|
||||
} as unknown as Room;
|
||||
|
||||
(mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom);
|
||||
mockClient.getRoom = jest.fn().mockReturnValue(mockRoom);
|
||||
mockClient.getFoci.mockReturnValue([{}]);
|
||||
});
|
||||
|
||||
describe("reacts to state changes", () => {
|
||||
|
||||
+66
-23
@@ -219,6 +219,7 @@ import {
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "./secret-storage";
|
||||
import { RegisterRequest, RegisterResponse } from "./@types/registration";
|
||||
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
|
||||
|
||||
export type Store = IStore;
|
||||
|
||||
@@ -382,6 +383,8 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
useE2eForGroupCall?: boolean;
|
||||
|
||||
livekitServiceURL?: string;
|
||||
|
||||
/**
|
||||
* Crypto callbacks provided by the application
|
||||
*/
|
||||
@@ -399,6 +402,12 @@ export interface ICreateClientOpts {
|
||||
* Default: false.
|
||||
*/
|
||||
isVoipWithNoMediaAllowed?: boolean;
|
||||
|
||||
/**
|
||||
* If true, group calls will not establish media connectivity and only create the signaling events,
|
||||
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
|
||||
*/
|
||||
useLivekitForGroupCalls?: boolean;
|
||||
}
|
||||
|
||||
export interface IMatrixClientCreateOpts extends ICreateClientOpts {
|
||||
@@ -1211,6 +1220,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public baseUrl: string;
|
||||
public readonly isVoipWithNoMediaAllowed;
|
||||
|
||||
public useLivekitForGroupCalls: boolean;
|
||||
|
||||
// Note: these are all `protected` to let downstream consumers make mistakes if they want to.
|
||||
// We don't technically support this usage, but have reasons to do this.
|
||||
|
||||
@@ -1258,12 +1269,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
private useE2eForGroupCall = true;
|
||||
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
||||
public livekitServiceURL?: string;
|
||||
|
||||
private _secretStorage: ServerSideSecretStorageImpl;
|
||||
|
||||
// A manager for determining which invites should be ignored.
|
||||
public readonly ignoredInvites: IgnoredInvites;
|
||||
|
||||
public readonly matrixRTC: MatrixRTCSessionManager;
|
||||
|
||||
public constructor(opts: IMatrixClientCreateOpts) {
|
||||
super();
|
||||
|
||||
@@ -1317,6 +1331,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.pickleKey = opts.pickleKey;
|
||||
}
|
||||
|
||||
this.useLivekitForGroupCalls = Boolean(opts.useLivekitForGroupCalls);
|
||||
|
||||
this.scheduler = opts.scheduler;
|
||||
if (this.scheduler) {
|
||||
this.scheduler.setProcessFunction(async (eventToSend: MatrixEvent) => {
|
||||
@@ -1344,6 +1360,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.on(ClientEvent.Sync, this.startCallEventHandler);
|
||||
}
|
||||
|
||||
// NB. We initialise MatrixRTC whether we have call support or not: this is just
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
this.timelineSupport = Boolean(opts.timelineSupport);
|
||||
@@ -1360,6 +1380,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
|
||||
|
||||
this.livekitServiceURL = opts.livekitServiceURL;
|
||||
|
||||
// List of which rooms have encryption enabled: separate from crypto because
|
||||
// we still want to know which rooms are encrypted even if crypto is disabled:
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
@@ -1442,6 +1464,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return;
|
||||
}
|
||||
this.clientRunning = true;
|
||||
|
||||
this.on(ClientEvent.Sync, this.startMatrixRTC);
|
||||
|
||||
// backwards compat for when 'opts' was 'historyLen'.
|
||||
if (typeof opts === "number") {
|
||||
opts = {
|
||||
@@ -1544,6 +1569,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public stopClient(): void {
|
||||
this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started
|
||||
|
||||
this.off(ClientEvent.Sync, this.startMatrixRTC);
|
||||
|
||||
if (!this.clientRunning) return; // already stopped
|
||||
|
||||
logger.log("stopping MatrixClient");
|
||||
@@ -1568,6 +1595,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.toDeviceMessageQueue.stop();
|
||||
|
||||
this.matrixRTC.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1748,8 +1777,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
let indexedDB: IDBFactory;
|
||||
try {
|
||||
indexedDB = global.indexedDB;
|
||||
if (!indexedDB) return; // No indexedDB support
|
||||
} catch (e) {
|
||||
// No indexeddb support
|
||||
// No indexedDB support
|
||||
return;
|
||||
}
|
||||
for (const dbname of [
|
||||
@@ -1937,9 +1967,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
dataChannelsEnabled || this.isVoipWithNoMediaAllowed,
|
||||
dataChannelOptions,
|
||||
this.isVoipWithNoMediaAllowed,
|
||||
this.useLivekitForGroupCalls,
|
||||
this.livekitServiceURL,
|
||||
).create();
|
||||
}
|
||||
|
||||
public getLivekitServiceURL(): string | undefined {
|
||||
return this.livekitServiceURL;
|
||||
}
|
||||
|
||||
// This shouldn't need to exist, but the widget API has startup ordering problems that
|
||||
// mean it doesn't know the livekit URL fast enough: remove this once this is fixed.
|
||||
public setLivekitServiceURL(newURL: string): void {
|
||||
this.livekitServiceURL = newURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an initial state for the given room has been processed by the
|
||||
* client and the client is aware of any ongoing group calls. Awaiting on
|
||||
@@ -2848,6 +2890,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param event - event to be checked
|
||||
* @returns The event information.
|
||||
* @deprecated Prefer {@link CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}.
|
||||
*/
|
||||
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
|
||||
if (!this.cryptoBackend) {
|
||||
@@ -3469,9 +3512,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
await this.cryptoBackend.deleteKeyBackupVersion(version);
|
||||
}
|
||||
|
||||
private makeKeyBackupPath(roomId: undefined, sessionId: undefined, version?: string): IKeyBackupPath;
|
||||
private makeKeyBackupPath(roomId: string, sessionId: undefined, version?: string): IKeyBackupPath;
|
||||
private makeKeyBackupPath(roomId: string, sessionId: string, version?: string): IKeyBackupPath;
|
||||
private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath {
|
||||
let path: string;
|
||||
if (sessionId !== undefined) {
|
||||
@@ -3791,22 +3831,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
let totalKeyCount = 0;
|
||||
let keys: IMegolmSessionData[] = [];
|
||||
|
||||
const path = this.makeKeyBackupPath(targetRoomId!, targetSessionId!, backupInfo.version);
|
||||
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
|
||||
|
||||
const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => {
|
||||
return privKey;
|
||||
});
|
||||
const backupDecryptor = await this.cryptoBackend.getBackupDecryptor(backupInfo, privKey);
|
||||
|
||||
const untrusted = algorithm.untrusted;
|
||||
const untrusted = !backupDecryptor.sourceTrusted;
|
||||
|
||||
try {
|
||||
// If the pubkey computed from the private data we've been given
|
||||
// doesn't match the one in the auth_data, the user has entered
|
||||
// a different recovery key / the wrong passphrase.
|
||||
if (!(await algorithm.keyMatches(privKey))) {
|
||||
return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
|
||||
}
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error(`restoreKeyBackup expects Uint8Array, got ${privKey}`);
|
||||
@@ -3840,7 +3871,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!roomData.sessions) continue;
|
||||
|
||||
totalKeyCount += Object.keys(roomData.sessions).length;
|
||||
const roomKeys = await algorithm.decryptSessions(roomData.sessions);
|
||||
const roomKeys = await backupDecryptor.decryptSessions(roomData.sessions);
|
||||
for (const k of roomKeys) {
|
||||
k.room_id = roomId;
|
||||
keys.push(k);
|
||||
@@ -3849,14 +3880,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else if ((res as IRoomKeysResponse).sessions) {
|
||||
const sessions = (res as IRoomKeysResponse).sessions;
|
||||
totalKeyCount = Object.keys(sessions).length;
|
||||
keys = await algorithm.decryptSessions(sessions);
|
||||
keys = await backupDecryptor.decryptSessions(sessions);
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId!;
|
||||
}
|
||||
} else {
|
||||
totalKeyCount = 1;
|
||||
try {
|
||||
const [key] = await algorithm.decryptSessions({
|
||||
const [key] = await backupDecryptor.decryptSessions({
|
||||
[targetSessionId!]: res as IKeyBackupSession,
|
||||
});
|
||||
key.room_id = targetRoomId!;
|
||||
@@ -3867,7 +3898,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
algorithm.free();
|
||||
backupDecryptor.free();
|
||||
}
|
||||
|
||||
await this.cryptoBackend.importRoomKeys(keys, {
|
||||
@@ -3876,7 +3907,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
source: "backup",
|
||||
});
|
||||
|
||||
await this.checkKeyBackup();
|
||||
/// in case entering the passphrase would add a new signature?
|
||||
await this.cryptoBackend.checkKeyBackupAndEnable();
|
||||
|
||||
return { total: totalKeyCount, imported: keys.length };
|
||||
}
|
||||
@@ -7057,12 +7089,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
private startCallEventHandler = (): void => {
|
||||
if (this.isInitialSyncComplete()) {
|
||||
this.callEventHandler!.start();
|
||||
this.groupCallEventHandler!.start();
|
||||
if (supportsMatrixCall()) {
|
||||
this.callEventHandler!.start();
|
||||
this.groupCallEventHandler!.start();
|
||||
}
|
||||
|
||||
this.off(ClientEvent.Sync, this.startCallEventHandler);
|
||||
}
|
||||
};
|
||||
|
||||
private startMatrixRTC = (): void => {
|
||||
if (this.isInitialSyncComplete()) {
|
||||
this.matrixRTC.start();
|
||||
|
||||
this.off(ClientEvent.Sync, this.startMatrixRTC);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Once the client has been initialised, we want to clear notifications we
|
||||
* know for a fact should be here.
|
||||
|
||||
@@ -20,6 +20,8 @@ import { Room } from "../models/room";
|
||||
import { CryptoApi } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
import { IMegolmSessionData } from "../@types/crypto";
|
||||
|
||||
/**
|
||||
* Common interface for the crypto implementations
|
||||
@@ -99,6 +101,13 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @deprecated Unneeded for the new crypto
|
||||
*/
|
||||
checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a backup decryptor capable of decrypting megolm session data encrypted with the given backup information.
|
||||
* @param backupInfo - The backup information
|
||||
* @param privKey - The private decryption key.
|
||||
*/
|
||||
getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
@@ -203,9 +212,45 @@ export interface EventDecryptionResult {
|
||||
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
|
||||
*/
|
||||
claimedEd25519Key?: string;
|
||||
/**
|
||||
* Whether the keys for this event have been received via an unauthenticated source (eg via key forwards, or
|
||||
* restored from backup)
|
||||
*/
|
||||
untrusted?: boolean;
|
||||
/**
|
||||
* The sender doesn't authorize the unverified devices to decrypt his messages
|
||||
*/
|
||||
encryptedDisabledForUnverifiedDevices?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for decrypting megolm session data retrieved from a remote backup.
|
||||
* The result of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
export interface BackupDecryptor {
|
||||
/**
|
||||
* Whether keys retrieved from this backup can be trusted.
|
||||
*
|
||||
* Depending on the backup algorithm, keys retrieved from the backup can be trusted or not.
|
||||
* If false, keys retrieved from the backup must be considered unsafe (authenticity cannot be guaranteed).
|
||||
* It could be by design (deniability) or for some technical reason (eg asymmetric encryption).
|
||||
*/
|
||||
readonly sourceTrusted: boolean;
|
||||
|
||||
/**
|
||||
*
|
||||
* Decrypt megolm session data retrieved from backup.
|
||||
*
|
||||
* @param ciphertexts - a Record of sessionId to session data.
|
||||
*
|
||||
* @returns An array of decrypted `IMegolmSessionData`
|
||||
*/
|
||||
decryptSessions(ciphertexts: Record<string, KeyBackupSession>): Promise<IMegolmSessionData[]>;
|
||||
|
||||
/**
|
||||
* Free any resources held by this decryptor.
|
||||
*
|
||||
* Should be called once the decryptor is no longer needed.
|
||||
*/
|
||||
free(): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base64 encoding and decoding utility for crypo.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescri
|
||||
import { VerificationRequest } from "./crypto-api/verification";
|
||||
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
|
||||
import { ISignatures } from "./@types/signed";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
|
||||
/**
|
||||
* Public interface to the cryptography parts of the js-sdk
|
||||
@@ -265,6 +266,16 @@ export interface CryptoApi {
|
||||
*/
|
||||
createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey>;
|
||||
|
||||
/**
|
||||
* Get information about the encryption of the given event.
|
||||
*
|
||||
* @param event - the event to get information for
|
||||
*
|
||||
* @returns `null` if the event is not encrypted, or has not (yet) been successfully decrypted. Otherwise, an
|
||||
* object with information about the encryption of the event.
|
||||
*/
|
||||
getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Device/User verification
|
||||
@@ -665,5 +676,57 @@ export interface GeneratedSecretStorageKey {
|
||||
encodedPrivateKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type of {@link CryptoApi#getEncryptionInfoForEvent}.
|
||||
*/
|
||||
export interface EventEncryptionInfo {
|
||||
/** "Shield" to be shown next to this event representing its verification status */
|
||||
shieldColour: EventShieldColour;
|
||||
|
||||
/**
|
||||
* `null` if `shieldColour` is `EventShieldColour.NONE`; otherwise a reason code for the shield in `shieldColour`.
|
||||
*/
|
||||
shieldReason: EventShieldReason | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of shield to be shown for {@link EventEncryptionInfo#shieldColour}.
|
||||
*/
|
||||
export enum EventShieldColour {
|
||||
NONE,
|
||||
GREY,
|
||||
RED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason codes for {@link EventEncryptionInfo#shieldReason}.
|
||||
*/
|
||||
export enum EventShieldReason {
|
||||
/** An unknown reason from the crypto library (if you see this, it is a bug in matrix-js-sdk). */
|
||||
UNKNOWN,
|
||||
|
||||
/** "Encrypted by an unverified user." */
|
||||
UNVERIFIED_IDENTITY,
|
||||
|
||||
/** "Encrypted by a device not verified by its owner." */
|
||||
UNSIGNED_DEVICE,
|
||||
|
||||
/** "Encrypted by an unknown or deleted device." */
|
||||
UNKNOWN_DEVICE,
|
||||
|
||||
/**
|
||||
* "The authenticity of this encrypted message can't be guaranteed on this device."
|
||||
*
|
||||
* ie: the key has been forwarded, or retrieved from an insecure backup.
|
||||
*/
|
||||
AUTHENTICITY_NOT_GUARANTEED,
|
||||
|
||||
/**
|
||||
* The (deprecated) sender_key field in the event does not match the Ed25519 key of the device that sent us the
|
||||
* decryption keys.
|
||||
*/
|
||||
MISMATCHED_SENDER_KEY,
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
export * from "./crypto-api/keybackup";
|
||||
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISigned } from "../@types/signed";
|
||||
import { IEncryptedPayload } from "../crypto/aes";
|
||||
|
||||
export interface Curve25519AuthData {
|
||||
public_key: string;
|
||||
@@ -68,3 +69,21 @@ export interface KeyBackupCheck {
|
||||
backupInfo: KeyBackupInfo;
|
||||
trustInfo: BackupTrustInfo;
|
||||
}
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface KeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> {
|
||||
first_message_index: number;
|
||||
forwarded_count: number;
|
||||
is_verified: boolean;
|
||||
session_data: T;
|
||||
}
|
||||
|
||||
export interface KeyBackupRoomSessions {
|
||||
[sessionId: string]: KeyBackupSession;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { CryptoEvent } from "./index";
|
||||
import { crypto } from "./crypto";
|
||||
import { ClientPrefix, HTTPError, MatrixError, Method } from "../http-api";
|
||||
import { BackupTrustInfo } from "../crypto-api/keybackup";
|
||||
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
|
||||
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
@@ -878,3 +879,32 @@ export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): Backup
|
||||
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link BackupDecryptor} for the libolm crypto backend.
|
||||
*/
|
||||
export class LibOlmBackupDecryptor implements BackupDecryptor {
|
||||
private algorithm: BackupAlgorithm;
|
||||
public readonly sourceTrusted: boolean;
|
||||
|
||||
public constructor(algorithm: BackupAlgorithm) {
|
||||
this.algorithm = algorithm;
|
||||
this.sourceTrusted = !algorithm.untrusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#free}
|
||||
*/
|
||||
public free(): void {
|
||||
this.algorithm.free();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#decryptSessions}
|
||||
*/
|
||||
public async decryptSessions(
|
||||
sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>,
|
||||
): Promise<IMegolmSessionData[]> {
|
||||
return await this.algorithm.decryptSessions(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
+90
-3
@@ -50,7 +50,7 @@ import { IllegalMethod } from "./verification/IllegalMethod";
|
||||
import { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
||||
import { DehydrationManager } from "./dehydration";
|
||||
import { BackupManager, backupTrustInfoFromLegacyTrustInfo } from "./backup";
|
||||
import { BackupManager, LibOlmBackupDecryptor, backupTrustInfoFromLegacyTrustInfo } from "./backup";
|
||||
import { IStore } from "../store";
|
||||
import { Room, RoomEvent } from "../models/room";
|
||||
import { RoomMember, RoomMemberEvent } from "../models/room-member";
|
||||
@@ -73,7 +73,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import {
|
||||
@@ -91,6 +91,9 @@ import {
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
@@ -98,7 +101,7 @@ import {
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
import { ClientPrefix, Method } from "../http-api";
|
||||
import { ClientPrefix, MatrixError, Method } from "../http-api";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type {
|
||||
@@ -1842,6 +1845,28 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
// the backup with the new key (if not already signed)?
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => {
|
||||
return privKey;
|
||||
});
|
||||
|
||||
// If the pubkey computed from the private data we've been given
|
||||
// doesn't match the one in the auth_data, the user has entered
|
||||
// a different recovery key / the wrong passphrase.
|
||||
if (!(await algorithm.keyMatches(privKey))) {
|
||||
return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
|
||||
}
|
||||
|
||||
return new LibOlmBackupDecryptor(algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a set of keys as our own, trusted, cross-signing keys.
|
||||
*
|
||||
@@ -2701,6 +2726,68 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return ret as IEncryptedEventInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
|
||||
*/
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
const encryptionInfo = this.getEventEncryptionInfo(event);
|
||||
if (!encryptionInfo.encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderId = event.getSender();
|
||||
if (!senderId || encryptionInfo.mismatchedSender) {
|
||||
// something definitely wrong is going on here
|
||||
return {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
const userTrust = this.checkUserTrust(senderId);
|
||||
if (!userTrust.isCrossSigningVerified()) {
|
||||
// If the message is unauthenticated, then display a grey
|
||||
// shield, otherwise if the user isn't cross-signed then
|
||||
// nothing's needed
|
||||
if (!encryptionInfo.authenticated) {
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
};
|
||||
} else {
|
||||
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
|
||||
}
|
||||
}
|
||||
|
||||
const eventSenderTrust =
|
||||
senderId &&
|
||||
encryptionInfo.sender &&
|
||||
(await this.getDeviceVerificationStatus(senderId, encryptionInfo.sender.deviceId));
|
||||
|
||||
if (!eventSenderTrust) {
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||
};
|
||||
}
|
||||
|
||||
if (!eventSenderTrust.isVerified()) {
|
||||
return {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNVERIFIED_IDENTITY,
|
||||
};
|
||||
}
|
||||
|
||||
if (!encryptionInfo.authenticated) {
|
||||
return {
|
||||
shieldColour: EventShieldColour.GREY,
|
||||
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||
};
|
||||
}
|
||||
|
||||
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the current outbound group session to be discarded such
|
||||
* that another one will be created next time an event is sent.
|
||||
|
||||
+3
-20
@@ -14,31 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IEncryptedPayload } from "./aes";
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IKeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> {
|
||||
first_message_index: number;
|
||||
forwarded_count: number;
|
||||
is_verified: boolean;
|
||||
session_data: T;
|
||||
}
|
||||
|
||||
export interface IKeyBackupRoomSessions {
|
||||
[sessionId: string]: IKeyBackupSession;
|
||||
}
|
||||
|
||||
// Export for backward compatibility
|
||||
export type {
|
||||
Curve25519AuthData as ICurve25519AuthData,
|
||||
Aes256AuthData as IAes256AuthData,
|
||||
KeyBackupInfo as IKeyBackupInfo,
|
||||
Curve25519SessionData,
|
||||
KeyBackupSession as IKeyBackupSession,
|
||||
KeyBackupRoomSessions as IKeyBackupRoomSessions,
|
||||
} from "../crypto-api/keybackup";
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as bs58 from "bs58";
|
||||
// picked arbitrarily but to try & avoid clashing with any bitcoin ones
|
||||
// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
|
||||
const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01];
|
||||
const KEY_SIZE = 32;
|
||||
|
||||
export function encodeRecoveryKey(key: ArrayLike<number>): string | undefined {
|
||||
const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
|
||||
@@ -52,11 +53,9 @@ export function decodeRecoveryKey(recoveryKey: string): Uint8Array {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) {
|
||||
if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + KEY_SIZE + 1) {
|
||||
throw new Error("Incorrect length");
|
||||
}
|
||||
|
||||
return Uint8Array.from(
|
||||
result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH),
|
||||
);
|
||||
return Uint8Array.from(result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + KEY_SIZE));
|
||||
}
|
||||
|
||||
+28
-1
@@ -29,7 +29,14 @@ import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { ISendEventResponse } from "./@types/requests";
|
||||
import { EventType } from "./@types/event";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
|
||||
import {
|
||||
MatrixClient,
|
||||
ClientEvent,
|
||||
IMatrixClientCreateOpts,
|
||||
IStartClientOpts,
|
||||
SendToDeviceContentMap,
|
||||
IOpenIDToken,
|
||||
} from "./client";
|
||||
import { SyncApi, SyncState } from "./sync";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk";
|
||||
import { User } from "./models/user";
|
||||
@@ -153,6 +160,12 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
// Open communication with the host
|
||||
widgetApi.start();
|
||||
// Send a content loaded event now we've started the widget API
|
||||
// Note that element-web currently does not use waitForIFrameLoad=false and so
|
||||
// does *not* (yes, that is the right way around) wait for this event. Let's
|
||||
// start sending this, then once this has rolled out, we can change element-web to
|
||||
// use waitForIFrameLoad=false and have a widget API that's less racy.
|
||||
widgetApi.sendContentLoaded();
|
||||
}
|
||||
|
||||
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
|
||||
@@ -197,6 +210,8 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info("Finished backfilling events");
|
||||
|
||||
this.matrixRTC.start();
|
||||
|
||||
// Watch for TURN servers, if requested
|
||||
if (this.capabilities.turnServers) this.watchTurnServers();
|
||||
}
|
||||
@@ -241,6 +256,18 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getOpenIdToken(): Promise<IOpenIDToken> {
|
||||
const token = await this.widgetApi.requestOpenIDConnectToken();
|
||||
// the IOpenIDCredentials from the widget-api and IOpenIDToken form the matrix-js-sdk are compatible.
|
||||
// we still recreate the token to make this transparent and catch'able by the linter in case the types change in the future.
|
||||
return <IOpenIDToken>{
|
||||
access_token: token.access_token,
|
||||
expires_in: token.expires_in,
|
||||
matrix_server_name: token.matrix_server_name,
|
||||
token_type: token.token_type,
|
||||
};
|
||||
}
|
||||
|
||||
public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, RoomMember } from "../matrix";
|
||||
import { deepCompare } from "../utils";
|
||||
import { Focus } from "./focus";
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
|
||||
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
|
||||
export interface CallMembershipData {
|
||||
application?: string;
|
||||
call_id: string;
|
||||
scope: CallScope;
|
||||
device_id: string;
|
||||
created_ts?: number;
|
||||
expires: number;
|
||||
foci_active?: Focus[];
|
||||
}
|
||||
|
||||
export class CallMembership {
|
||||
public static equal(a: CallMembership, b: CallMembership): boolean {
|
||||
return deepCompare(a.data, b.data);
|
||||
}
|
||||
|
||||
public constructor(private parentEvent: MatrixEvent, private data: CallMembershipData) {
|
||||
if (typeof data.expires !== "number") throw new Error("Malformed membership: expires must be numeric");
|
||||
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
|
||||
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
|
||||
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
|
||||
if (!parentEvent.sender) throw new Error("Invalid parent event: sender is null");
|
||||
}
|
||||
|
||||
public get member(): RoomMember {
|
||||
return this.parentEvent.sender!;
|
||||
}
|
||||
|
||||
public get callId(): string {
|
||||
return this.data.call_id;
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
return this.data.device_id;
|
||||
}
|
||||
|
||||
public get application(): string | undefined {
|
||||
return this.data.application;
|
||||
}
|
||||
|
||||
public get scope(): CallScope {
|
||||
return this.data.scope;
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
return this.data.created_ts ?? this.parentEvent.getTs();
|
||||
}
|
||||
|
||||
public getAbsoluteExpiry(): number {
|
||||
return this.createdTs() + this.data.expires;
|
||||
}
|
||||
|
||||
// gets the expiry time of the event, converted into the device's local time
|
||||
public getLocalExpiry(): number {
|
||||
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
|
||||
|
||||
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
|
||||
|
||||
return localCreationTs + this.data.expires;
|
||||
}
|
||||
|
||||
public getMsUntilExpiry(): number {
|
||||
return this.getLocalExpiry() - Date.now();
|
||||
}
|
||||
|
||||
public isExpired(): boolean {
|
||||
return this.getAbsoluteExpiry() < this.parentEvent.getTs() + this.parentEvent.getLocalAge();
|
||||
}
|
||||
|
||||
public getActiveFoci(): Focus[] {
|
||||
return this.data.foci_active ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { EventTimeline } from "../models/event-timeline";
|
||||
import { Room } from "../models/room";
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType } from "../@types/event";
|
||||
import { CallMembership, CallMembershipData } from "./CallMembership";
|
||||
import { Focus } from "./focus";
|
||||
import { MatrixEvent } from "../matrix";
|
||||
|
||||
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
|
||||
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
|
||||
const CALL_MEMBER_EVENT_RETRY_DELAY_MIN = 3000;
|
||||
|
||||
export enum MatrixRTCSessionEvent {
|
||||
// A member joined, left, or updated a property of their membership.
|
||||
MembershipsChanged = "memberships_changed",
|
||||
// We joined or left the session: our own local idea of whether we are joined,
|
||||
// separate from MembershipsChanged, ie. independent of whether our member event
|
||||
// has succesfully gone through.
|
||||
JoinStateChanged = "join_state_changed",
|
||||
}
|
||||
|
||||
export type MatrixRTCSessionEventHandlerMap = {
|
||||
[MatrixRTCSessionEvent.MembershipsChanged]: (
|
||||
oldMemberships: CallMembership[],
|
||||
newMemberships: CallMembership[],
|
||||
) => void;
|
||||
[MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// How many ms after we joined the call, that our membership should expire, or undefined
|
||||
// if we're not yet joined
|
||||
private relativeExpiry: number | undefined;
|
||||
|
||||
private memberEventTimeout?: ReturnType<typeof setTimeout>;
|
||||
private expiryTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private activeFoci: Focus[] | undefined;
|
||||
|
||||
private updateCallMembershipRunning = false;
|
||||
private needCallMembershipUpdate = false;
|
||||
|
||||
/**
|
||||
* Returns all the call memberships for a room, oldest first
|
||||
*/
|
||||
public static callMembershipsForRoom(room: Room): CallMembership[] {
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) {
|
||||
logger.warn("Couldn't get state for room " + room.roomId);
|
||||
throw new Error("Could't get state for room " + room.roomId);
|
||||
}
|
||||
const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix);
|
||||
|
||||
const callMemberships: CallMembership[] = [];
|
||||
for (const memberEvent of callMemberEvents) {
|
||||
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
|
||||
if (eventMemberships === undefined) {
|
||||
logger.warn(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(eventMemberships)) {
|
||||
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const membershipData of eventMemberships) {
|
||||
try {
|
||||
const membership = new CallMembership(memberEvent, membershipData);
|
||||
|
||||
if (membership.callId !== "" || membership.scope !== "m.room") {
|
||||
// for now, just ignore anything that isn't the a room scope call
|
||||
logger.info(`Ignoring user-scoped call`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (membership.isExpired()) {
|
||||
logger.info(
|
||||
`Ignoring expired device membership ${memberEvent.getSender()}/${membership.deviceId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
callMemberships.push(membership);
|
||||
} catch (e) {
|
||||
logger.warn("Couldn't construct call membership: ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callMemberships.sort((a, b) => a.createdTs() - b.createdTs());
|
||||
logger.debug(
|
||||
"Call memberships, in order: ",
|
||||
callMemberships.map((m) => [m.createdTs(), m.member.userId]),
|
||||
);
|
||||
|
||||
return callMemberships;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a the MatrixRTC for the room, whether there are currently active members or not
|
||||
*/
|
||||
public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession {
|
||||
const callMemberships = MatrixRTCSession.callMembershipsForRoom(room);
|
||||
|
||||
return new MatrixRTCSession(client, room, callMemberships);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly client: MatrixClient,
|
||||
public readonly room: Room,
|
||||
public memberships: CallMembership[],
|
||||
) {
|
||||
super();
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if we intend to be participating in the MatrixRTC session.
|
||||
*/
|
||||
public isJoined(): boolean {
|
||||
return this.relativeExpiry !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs cleanup & removes timers for client shutdown
|
||||
*/
|
||||
public stop(): void {
|
||||
this.leaveRoomSession();
|
||||
if (this.expiryTimeout) {
|
||||
clearTimeout(this.expiryTimeout);
|
||||
this.expiryTimeout = undefined;
|
||||
}
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces this user and device as joined to the MatrixRTC session,
|
||||
* and continues to update the membership event to keep it valid until
|
||||
* leaveRoomSession() is called
|
||||
* This will not subscribe to updates: remember to call subscribe() separately if
|
||||
* desired.
|
||||
* This method will return immediately and the session will be joined in the background.
|
||||
*/
|
||||
public joinRoomSession(activeFoci: Focus[]): void {
|
||||
if (this.isJoined()) {
|
||||
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Joining call session in room ${this.room.roomId}`);
|
||||
this.activeFoci = activeFoci;
|
||||
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
|
||||
// We don't wait for this, mostly because it may fail and schedule a retry, so this
|
||||
// function returning doesn't really mean anything at all.
|
||||
this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces this user and device as having left the MatrixRTC session
|
||||
* and stops scheduled updates.
|
||||
* This will not unsubscribe from updates: remember to call unsubscribe() separately if
|
||||
* desired.
|
||||
*/
|
||||
public leaveRoomSession(): void {
|
||||
if (!this.isJoined()) {
|
||||
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Leaving call session in room ${this.room.roomId}`);
|
||||
this.relativeExpiry = undefined;
|
||||
this.activeFoci = undefined;
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a timer for the soonest membership expiry
|
||||
*/
|
||||
private setExpiryTimer(): void {
|
||||
if (this.expiryTimeout) {
|
||||
clearTimeout(this.expiryTimeout);
|
||||
this.expiryTimeout = undefined;
|
||||
}
|
||||
|
||||
let soonestExpiry;
|
||||
for (const membership of this.memberships) {
|
||||
const thisExpiry = membership.getMsUntilExpiry();
|
||||
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
|
||||
soonestExpiry = thisExpiry;
|
||||
}
|
||||
}
|
||||
|
||||
if (soonestExpiry != undefined) {
|
||||
this.expiryTimeout = setTimeout(this.onMembershipUpdate, soonestExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
public getOldestMembership(): CallMembership | undefined {
|
||||
return this.memberships[0];
|
||||
}
|
||||
|
||||
public onMembershipUpdate = (): void => {
|
||||
const oldMemberships = this.memberships;
|
||||
this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room);
|
||||
|
||||
const changed =
|
||||
oldMemberships.length != this.memberships.length ||
|
||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||
|
||||
if (changed) {
|
||||
logger.info(`Memberships for call in room ${this.room.roomId} have changed: emitting`);
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||
}
|
||||
|
||||
this.setExpiryTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
* @param prevEvent - The previous version of our call membership, if any
|
||||
*/
|
||||
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
|
||||
if (this.relativeExpiry === undefined) {
|
||||
throw new Error("Tried to create our own membership event when we're not joined!");
|
||||
}
|
||||
|
||||
const m: CallMembershipData = {
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
application: "m.call",
|
||||
device_id: this.client.getDeviceId()!,
|
||||
expires: this.relativeExpiry,
|
||||
foci_active: this.activeFoci,
|
||||
};
|
||||
|
||||
if (prevMembership) m.created_ts = prevMembership.createdTs();
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if our membership event needs to be updated
|
||||
*/
|
||||
private membershipEventNeedsUpdate(
|
||||
myPrevMembershipData?: CallMembershipData,
|
||||
myPrevMembership?: CallMembership,
|
||||
): boolean {
|
||||
// work out if we need to update our membership event
|
||||
let needsUpdate = false;
|
||||
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
|
||||
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
|
||||
if (this.isJoined()) {
|
||||
// ...or if we are joined, but there's no valid membership event
|
||||
if (!myPrevMembership) {
|
||||
needsUpdate = true;
|
||||
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
|
||||
// ...or if the expiry time needs bumping
|
||||
needsUpdate = true;
|
||||
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
return needsUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new membership list given the old list alonng with this user's previous membership event
|
||||
* (if any) and this device's previous membership (if any)
|
||||
*/
|
||||
private makeNewMemberships(
|
||||
oldMemberships: CallMembershipData[],
|
||||
myCallMemberEvent?: MatrixEvent,
|
||||
myPrevMembership?: CallMembership,
|
||||
): CallMembershipData[] {
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localDeviceId) throw new Error("Local device ID is null!");
|
||||
|
||||
const filterExpired = (m: CallMembershipData): boolean => {
|
||||
let membershipObj;
|
||||
try {
|
||||
membershipObj = new CallMembership(myCallMemberEvent!, m);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !membershipObj.isExpired();
|
||||
};
|
||||
|
||||
const transformMemberships = (m: CallMembershipData): CallMembershipData => {
|
||||
if (m.created_ts === undefined) {
|
||||
// we need to fill this in with the origin_server_ts from its original event
|
||||
m.created_ts = myCallMemberEvent!.getTs();
|
||||
}
|
||||
|
||||
return m;
|
||||
};
|
||||
|
||||
// Filter our any invalid or expired memberships, and also our own - we'll add that back in next
|
||||
let newMemberships = oldMemberships.filter(filterExpired).filter((m) => m.device_id !== localDeviceId);
|
||||
|
||||
// Fix up any memberships that need their created_ts adding
|
||||
newMemberships = newMemberships.map(transformMemberships);
|
||||
|
||||
// If we're joined, add our own
|
||||
if (this.isJoined()) {
|
||||
newMemberships.push(this.makeMyMembership(myPrevMembership));
|
||||
}
|
||||
|
||||
return newMemberships;
|
||||
}
|
||||
|
||||
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
|
||||
if (this.updateCallMembershipRunning) {
|
||||
this.needCallMembershipUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateCallMembershipRunning = true;
|
||||
try {
|
||||
// if anything triggers an update while the update is running, do another update afterwards
|
||||
do {
|
||||
this.needCallMembershipUpdate = false;
|
||||
await this.updateCallMembershipEvent();
|
||||
} while (this.needCallMembershipUpdate);
|
||||
} finally {
|
||||
this.updateCallMembershipRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
private async updateCallMembershipEvent(): Promise<void> {
|
||||
if (this.memberEventTimeout) {
|
||||
clearTimeout(this.memberEventTimeout);
|
||||
this.memberEventTimeout = undefined;
|
||||
}
|
||||
|
||||
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
|
||||
|
||||
const localUserId = this.client.getUserId();
|
||||
const localDeviceId = this.client.getDeviceId();
|
||||
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
|
||||
|
||||
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
|
||||
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
|
||||
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
|
||||
|
||||
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
|
||||
let myPrevMembership;
|
||||
try {
|
||||
if (myCallMemberEvent && myPrevMembershipData) {
|
||||
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
|
||||
}
|
||||
} catch (e) {
|
||||
// This would indicate a bug or something weird if our own call membership
|
||||
// wasn't valid
|
||||
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
|
||||
}
|
||||
|
||||
if (myPrevMembership) {
|
||||
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
|
||||
}
|
||||
|
||||
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
|
||||
// nothing to do - reschedule the check again
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = {
|
||||
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
|
||||
};
|
||||
|
||||
let resendDelay;
|
||||
try {
|
||||
await this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
newContent,
|
||||
localUserId,
|
||||
);
|
||||
logger.info(`Sent updated call member event.`);
|
||||
|
||||
// check periodically to see if we need to refresh our member event
|
||||
if (this.isJoined()) resendDelay = MEMBER_EVENT_CHECK_PERIOD;
|
||||
} catch (e) {
|
||||
resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
|
||||
logger.warn(`Failed to send call member event: retrying in ${resendDelay}`);
|
||||
}
|
||||
|
||||
if (resendDelay) this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, resendDelay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../logger";
|
||||
import { MatrixClient, ClientEvent } from "../client";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession";
|
||||
|
||||
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",
|
||||
// All participants have left a given MatrixRTC session.
|
||||
SessionEnded = "session_ended",
|
||||
}
|
||||
|
||||
type EventHandlerMap = {
|
||||
[MatrixRTCSessionManagerEvents.SessionStarted]: (roomId: string, session: MatrixRTCSession) => void;
|
||||
[MatrixRTCSessionManagerEvents.SessionEnded]: (roomId: string, session: MatrixRTCSession) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds all active MatrixRTC session objects and creates new ones as events arrive.
|
||||
* This interface is UNSTABLE and may change without warning.
|
||||
*/
|
||||
export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionManagerEvents, EventHandlerMap> {
|
||||
// All the room-scoped sessions we know about. This will include any where the app
|
||||
// has queried for the MatrixRTC sessions in a room, whether it's ever had any members
|
||||
// or not). We keep a (lazily created) session object for every room to ensure that there
|
||||
// is only ever one single room session object for any given room for the lifetime of the
|
||||
// client: that way there can never be any code holding onto a stale object that is no
|
||||
// longer the correct session object for the room.
|
||||
private roomSessions = new Map<string, MatrixRTCSession>();
|
||||
|
||||
public constructor(private client: MatrixClient) {
|
||||
super();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
|
||||
// returing nothing, and breaks tests if you change it to return an empty array :'(
|
||||
for (const room of this.client.getRooms() ?? []) {
|
||||
const session = MatrixRTCSession.roomSessionForRoom(this.client, room);
|
||||
if (session.memberships.length > 0) {
|
||||
this.roomSessions.set(room.roomId, session);
|
||||
}
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
for (const sess of this.roomSessions.values()) {
|
||||
sess.stop();
|
||||
}
|
||||
this.roomSessions.clear();
|
||||
|
||||
this.client.removeListener(ClientEvent.Room, this.onRoom);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the main MatrixRTC session for a room, or undefined if there is
|
||||
* no current session
|
||||
*/
|
||||
public getActiveRoomSession(room: Room): MatrixRTCSession | undefined {
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the main MatrixRTC session for a room, returning an empty session
|
||||
* if no members are currently participating
|
||||
*/
|
||||
public getRoomSession(room: Room): MatrixRTCSession {
|
||||
if (!this.roomSessions.has(room.roomId)) {
|
||||
this.roomSessions.set(room.roomId, MatrixRTCSession.roomSessionForRoom(this.client, room));
|
||||
}
|
||||
|
||||
return this.roomSessions.get(room.roomId)!;
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
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()}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshRoom(room);
|
||||
};
|
||||
|
||||
private refreshRoom(room: Room): void {
|
||||
const isNewSession = !this.roomSessions.has(room.roomId);
|
||||
const sess = this.getRoomSession(room);
|
||||
|
||||
const wasActiveAndKnown = sess.memberships.length > 0 && !isNewSession;
|
||||
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
const nowActive = sess.memberships.length > 0;
|
||||
|
||||
if (wasActiveAndKnown && !nowActive) {
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
} else if (!wasActiveAndKnown && nowActive) {
|
||||
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Information about a MatrixRTC conference focus. The only attribute that
|
||||
* the js-sdk (currently) knows about is the type: applications can extend
|
||||
* this class for different types of focus.
|
||||
*/
|
||||
export interface Focus {
|
||||
type: string;
|
||||
}
|
||||
+1
-1
@@ -1026,7 +1026,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* signing the public curve25519 key with the ed25519 key.
|
||||
*
|
||||
* In general, applications should not use this method directly, but should
|
||||
* instead use MatrixClient.getEventSenderDeviceInfo.
|
||||
* instead use {@link CryptoApi#getEncryptionInfoForEvent}.
|
||||
*/
|
||||
public getClaimedEd25519Key(): string | null {
|
||||
return this.claimedEd25519Key;
|
||||
|
||||
+11
-5
@@ -849,7 +849,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the membership type (join | leave | invite) for the logged in user
|
||||
* @returns the membership type (join | leave | invite | knock) for the logged in user
|
||||
*/
|
||||
public getMyMembership(): string {
|
||||
return this.selfMembership ?? "leave";
|
||||
@@ -2947,12 +2947,18 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
// If the read receipt sent for the logged in user matches
|
||||
// the last event of the live timeline, then we know for a fact
|
||||
// that the user has read that message.
|
||||
// We can mark the room as read and not wait for the local echo
|
||||
// from synapse
|
||||
// that the user has read that message, so we can mark the room
|
||||
// as read and not wait for the remote echo from synapse.
|
||||
//
|
||||
// This needs to be done after the initial sync as we do not want this
|
||||
// logic to run whilst the room is being initialised
|
||||
if (this.client.isInitialSyncComplete() && userId === this.client.getUserId()) {
|
||||
//
|
||||
// We only do this for non-synthetic receipts, because
|
||||
// our intention is to do this when the user really did
|
||||
// just read a message, not when we are e.g. receiving
|
||||
// an event during the sync. More explanation at:
|
||||
// https://github.com/matrix-org/matrix-js-sdk/issues/3684
|
||||
if (!synthetic && this.client.isInitialSyncComplete() && userId === this.client.getUserId()) {
|
||||
const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1];
|
||||
if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) {
|
||||
receiptDestination.setUnread(NotificationCountType.Total, 0);
|
||||
|
||||
@@ -32,8 +32,6 @@ export class CrossSigningIdentity {
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
/** Called if the cross signing keys are imported from the secret storage */
|
||||
private readonly onCrossSigningKeysImport: () => Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -95,8 +93,6 @@ export class CrossSigningIdentity {
|
||||
// Sign the device with our cross-signing key and upload the signature
|
||||
const request: RustSdkCryptoJs.SignatureUploadRequest = await device.verify();
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
|
||||
await this.onCrossSigningKeysImport();
|
||||
}
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
|
||||
@@ -17,14 +17,23 @@ limitations under the License.
|
||||
import { OlmMachine, SignatureVerification } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { BackupTrustInfo, Curve25519AuthData, KeyBackupCheck, KeyBackupInfo } from "../crypto-api/keybackup";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
Curve25519AuthData,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
KeyBackupSession,
|
||||
Curve25519SessionData,
|
||||
} from "../crypto-api/keybackup";
|
||||
import { logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { CryptoEvent } from "../crypto";
|
||||
import { CryptoEvent, IMegolmSessionData } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { encodeUri } from "../utils";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { sleep } from "../utils";
|
||||
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
|
||||
import { IEncryptedPayload } from "../crypto/aes";
|
||||
|
||||
/** Authentification of the backup info, depends on algorithm */
|
||||
type AuthData = KeyBackupInfo["auth_data"];
|
||||
@@ -370,6 +379,51 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link BackupDecryptor} for the rust crypto backend.
|
||||
*/
|
||||
export class RustBackupDecryptor implements BackupDecryptor {
|
||||
private decryptionKey: RustSdkCryptoJs.BackupDecryptionKey;
|
||||
public sourceTrusted: boolean;
|
||||
|
||||
public constructor(decryptionKey: RustSdkCryptoJs.BackupDecryptionKey) {
|
||||
this.decryptionKey = decryptionKey;
|
||||
this.sourceTrusted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#decryptSessions}
|
||||
*/
|
||||
public async decryptSessions(
|
||||
ciphertexts: Record<string, KeyBackupSession<Curve25519SessionData | IEncryptedPayload>>,
|
||||
): Promise<IMegolmSessionData[]> {
|
||||
const keys: IMegolmSessionData[] = [];
|
||||
for (const [sessionId, sessionData] of Object.entries(ciphertexts)) {
|
||||
try {
|
||||
const decrypted = JSON.parse(
|
||||
await this.decryptionKey.decryptV1(
|
||||
sessionData.session_data.ephemeral,
|
||||
sessionData.session_data.mac,
|
||||
sessionData.session_data.ciphertext,
|
||||
),
|
||||
);
|
||||
decrypted.session_id = sessionId;
|
||||
keys.push(decrypted);
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link BackupDecryptor#free}
|
||||
*/
|
||||
public free(): void {
|
||||
this.decryptionKey.free();
|
||||
}
|
||||
}
|
||||
|
||||
export type RustBackupCryptoEvents =
|
||||
| CryptoEvent.KeyBackupStatus
|
||||
| CryptoEvent.KeyBackupSessionsRemaining
|
||||
|
||||
@@ -65,6 +65,9 @@ export async function initRustCrypto(
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
);
|
||||
await olmMachine.registerUserIdentityUpdatedCallback((userId: RustSdkCryptoJs.UserId) =>
|
||||
rustCrypto.onUserIdentityUpdated(userId),
|
||||
);
|
||||
|
||||
// Tell the OlmMachine to think about its outgoing requests before we hand control back to the application.
|
||||
//
|
||||
|
||||
+282
-44
@@ -23,13 +23,13 @@ import type { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { ClientPrefix, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { MapWithDefault, encodeUri } from "../utils";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
@@ -38,12 +38,17 @@ import {
|
||||
CrossSigningKeyInfo,
|
||||
CrossSigningStatus,
|
||||
CryptoCallbacks,
|
||||
Curve25519AuthData,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
GeneratedSecretStorageKey,
|
||||
ImportRoomKeyProgressData,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
KeyBackupSession,
|
||||
UserVerificationStatus,
|
||||
VerificationRequest,
|
||||
} from "../crypto-api";
|
||||
@@ -60,11 +65,13 @@ import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentif
|
||||
import { EventType } from "../@types/event";
|
||||
import { CryptoEvent } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupManager } from "./backup";
|
||||
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupDecryptor, RustBackupManager } from "./backup";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { randomString } from "../randomstring";
|
||||
import { ClientStoppedError } from "../errors";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { encodeBase64 } from "../common-crypto/base64";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
|
||||
|
||||
@@ -73,6 +80,8 @@ interface ISignableObject {
|
||||
unsigned?: object;
|
||||
}
|
||||
|
||||
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
*
|
||||
@@ -88,6 +97,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
/** whether {@link outgoingRequestLoop} is currently running */
|
||||
private outgoingRequestLoopRunning = false;
|
||||
|
||||
/**
|
||||
* whether we check the outgoing requests queue again after the current check finishes.
|
||||
*
|
||||
* This should never be `true` unless `outgoingRequestLoopRunning` is also true.
|
||||
*/
|
||||
private outgoingRequestLoopOneMoreLoop = false;
|
||||
|
||||
/** mapping of roomId → encryptor class */
|
||||
private roomEncryptors: Record<string, RoomEncryptor> = {};
|
||||
|
||||
@@ -97,6 +113,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
private crossSigningIdentity: CrossSigningIdentity;
|
||||
private readonly backupManager: RustBackupManager;
|
||||
|
||||
private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
|
||||
|
||||
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
||||
|
||||
public constructor(
|
||||
@@ -125,7 +143,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
super();
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine, this);
|
||||
|
||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||
this.reemitter.reEmit(this.backupManager, [
|
||||
@@ -134,17 +152,47 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
CryptoEvent.KeyBackupFailed,
|
||||
]);
|
||||
|
||||
// Fire if the cross signing keys are imported from the secret storage
|
||||
const onCrossSigningKeysImport = async (): Promise<void> => {
|
||||
const newVerification = await this.getUserVerificationStatus(this.userId);
|
||||
this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, newVerification);
|
||||
};
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(
|
||||
olmMachine,
|
||||
this.outgoingRequestProcessor,
|
||||
secretStorage,
|
||||
onCrossSigningKeysImport,
|
||||
);
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve a session from a key backup, if enough time
|
||||
* has elapsed since the last check for this session id.
|
||||
*/
|
||||
public async queryKeyBackupRateLimited(targetRoomId: string, targetSessionId: string): Promise<void> {
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
if (!backupKeys.decryptionKey) return;
|
||||
const version = backupKeys.backupVersion;
|
||||
|
||||
const now = new Date().getTime();
|
||||
if (
|
||||
!this.sessionLastCheckAttemptedTime[targetSessionId!] ||
|
||||
now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
|
||||
) {
|
||||
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
|
||||
|
||||
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
|
||||
$roomId: targetRoomId,
|
||||
$sessionId: targetSessionId,
|
||||
});
|
||||
|
||||
const res = await this.http.authedRequest<KeyBackupSession>(Method.Get, path, { version }, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
|
||||
if (this.stopped) return;
|
||||
|
||||
const backupDecryptor = new RustBackupDecryptor(backupKeys.decryptionKey);
|
||||
if (res) {
|
||||
const sessionsToImport: Record<string, KeyBackupSession> = {};
|
||||
sessionsToImport[targetSessionId] = res;
|
||||
const keys = await backupDecryptor.decryptSessions(sessionsToImport);
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId!;
|
||||
}
|
||||
await this.importRoomKeys(keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +256,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return await this.eventDecryptor.attemptEventDecryption(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of (deprecated) {@link MatrixClient#getEventEncryptionInfo}.
|
||||
*
|
||||
* @param event - event to inspect
|
||||
*/
|
||||
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
|
||||
// TODO: make this work properly. Or better, replace it.
|
||||
|
||||
@@ -732,6 +785,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
|
||||
*/
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
return this.eventDecryptor.getEncryptionInfoForEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns to-device verification requests that are already in progress for the given user id.
|
||||
*
|
||||
@@ -934,7 +994,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* @param key - the backup decryption key
|
||||
*/
|
||||
public async storeSessionBackupPrivateKey(key: Uint8Array): Promise<void> {
|
||||
const base64Key = Buffer.from(key).toString("base64");
|
||||
const base64Key = encodeBase64(key);
|
||||
|
||||
// TODO get version from backupManager
|
||||
await this.olmMachine.saveBackupDecryptionKey(RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64Key), "");
|
||||
@@ -1016,6 +1076,29 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
obj.signatures = Object.fromEntries(sigs.entries());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
|
||||
}
|
||||
|
||||
const authData = <Curve25519AuthData>backupInfo.auth_data;
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
|
||||
|
||||
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
||||
throw new Error(`getBackupDecryptor key mismatch error`);
|
||||
}
|
||||
|
||||
return new RustBackupDecryptor(backupDecryptionKey);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
@@ -1232,6 +1315,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for `OlmMachine.registerUserIdentityUpdatedCallback`
|
||||
*
|
||||
* Called by the rust-sdk whenever there is an update to any user's cross-signing status. We re-check their trust
|
||||
* status and emit a `UserTrustStatusChanged` event.
|
||||
*
|
||||
* @param userId - the user with the updated identity
|
||||
*/
|
||||
public async onUserIdentityUpdated(userId: RustSdkCryptoJs.UserId): Promise<void> {
|
||||
const newVerification = await this.getUserVerificationStatus(userId.toString());
|
||||
this.emit(CryptoEvent.UserTrustStatusChanged, userId.toString(), newVerification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a live event received via /sync.
|
||||
* See {@link ClientEventHandlerMap#event}
|
||||
@@ -1284,6 +1380,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
throw new Error("missing roomId in the event");
|
||||
}
|
||||
|
||||
logger.debug(`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`);
|
||||
|
||||
await this.olmMachine.receiveVerificationEvent(
|
||||
JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
@@ -1295,6 +1393,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}),
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
);
|
||||
|
||||
// that may have caused us to queue up outgoing requests, so make sure we send them.
|
||||
this.outgoingRequestLoop();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -1303,24 +1404,56 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private async outgoingRequestLoop(): Promise<void> {
|
||||
/** start the outgoing request loop if it is not already running */
|
||||
private outgoingRequestLoop(): void {
|
||||
if (this.outgoingRequestLoopRunning) {
|
||||
// The loop is already running, but we have reason to believe that there may be new items in the queue.
|
||||
//
|
||||
// There is potential for a race whereby the item is added *after* `OlmMachine.outgoingRequests` checks
|
||||
// the queue, but *before* it returns. In such a case, the item could sit there unnoticed for some time.
|
||||
//
|
||||
// In order to circumvent the race, we set a flag which tells the loop to go round once again even if the
|
||||
// queue appears to be empty.
|
||||
this.outgoingRequestLoopOneMoreLoop = true;
|
||||
return;
|
||||
}
|
||||
// fire off the loop in the background
|
||||
this.outgoingRequestLoopInner().catch((e) => {
|
||||
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
|
||||
});
|
||||
}
|
||||
|
||||
private async outgoingRequestLoopInner(): Promise<void> {
|
||||
/* istanbul ignore if */
|
||||
if (this.outgoingRequestLoopRunning) {
|
||||
throw new Error("Cannot run two outgoing request loops");
|
||||
}
|
||||
this.outgoingRequestLoopRunning = true;
|
||||
try {
|
||||
while (!this.stopped) {
|
||||
// we clear the "one more loop" flag just before calling `OlmMachine.outgoingRequests()`, so we can tell
|
||||
// if `this.outgoingRequestLoop()` was called while `OlmMachine.outgoingRequests()` was running.
|
||||
this.outgoingRequestLoopOneMoreLoop = false;
|
||||
|
||||
logger.debug("Calling OlmMachine.outgoingRequests()");
|
||||
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
|
||||
if (outgoingRequests.length == 0 || this.stopped) {
|
||||
// no more messages to send (or we have been told to stop): exit the loop
|
||||
|
||||
if (this.stopped) {
|
||||
// we've been told to stop while `outgoingRequests` was running: exit the loop without processing
|
||||
// any of the returned requests (anything important will happen next time the client starts.)
|
||||
return;
|
||||
}
|
||||
|
||||
if (outgoingRequests.length === 0 && !this.outgoingRequestLoopOneMoreLoop) {
|
||||
// `OlmMachine.outgoingRequests` returned no messages, and there was no call to
|
||||
// `this.outgoingRequestLoop()` while it was running. We can stop the loop for a while.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const msg of outgoingRequests) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(msg as OutgoingRequest);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
|
||||
} finally {
|
||||
this.outgoingRequestLoopRunning = false;
|
||||
}
|
||||
@@ -1337,7 +1470,7 @@ class EventDecryptor {
|
||||
() => new MapWithDefault<string, Set<MatrixEvent>>(() => new Set()),
|
||||
);
|
||||
|
||||
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine) {}
|
||||
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine, private readonly crypto: RustCrypto) {}
|
||||
|
||||
public async attemptEventDecryption(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
logger.info("Attempting decryption of event", event);
|
||||
@@ -1347,28 +1480,78 @@ class EventDecryptor {
|
||||
// (fixes https://github.com/vector-im/element-web/issues/5001)
|
||||
this.addEventToPendingList(event);
|
||||
|
||||
const res = (await this.olmMachine.decryptRoomEvent(
|
||||
JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
type: event.getWireType(),
|
||||
sender: event.getSender(),
|
||||
state_key: event.getStateKey(),
|
||||
content: event.getWireContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
}),
|
||||
try {
|
||||
const res = (await this.olmMachine.decryptRoomEvent(
|
||||
stringifyEvent(event),
|
||||
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
|
||||
)) as RustSdkCryptoJs.DecryptedRoomEvent;
|
||||
|
||||
// Success. We can remove the event from the pending list, if
|
||||
// that hasn't already happened.
|
||||
this.removeEventFromPendingList(event);
|
||||
|
||||
return {
|
||||
clearEvent: JSON.parse(res.event),
|
||||
claimedEd25519Key: res.senderClaimedEd25519Key,
|
||||
senderCurve25519Key: res.senderCurve25519Key,
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
} catch (err) {
|
||||
// We need to map back to regular decryption errors (used for analytics for example)
|
||||
// The DecryptionErrors are used by react-sdk so is implicitly part of API, but poorly typed
|
||||
if (err instanceof RustSdkCryptoJs.MegolmDecryptionError) {
|
||||
const content = event.getWireContent();
|
||||
let jsError;
|
||||
switch (err.code) {
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey: {
|
||||
jsError = new DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
|
||||
jsError = new DecryptionError(
|
||||
"OLM_UNKNOWN_MESSAGE_INDEX",
|
||||
"The sender's device has not sent us the keys for this message at this index.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
|
||||
break;
|
||||
}
|
||||
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
|
||||
// Just put it on the `UNABLE_TO_DECRYPT` bucket.
|
||||
default: {
|
||||
jsError = new DecryptionError("UNABLE_TO_DECRYPT", err.description, {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw jsError;
|
||||
}
|
||||
throw new DecryptionError("UNABLE_TO_DECRYPT", "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
if (!event.getClearContent()) {
|
||||
// not successfully decrypted
|
||||
return null;
|
||||
}
|
||||
|
||||
const encryptionInfo = await this.olmMachine.getRoomEventEncryptionInfo(
|
||||
stringifyEvent(event),
|
||||
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
|
||||
)) as RustSdkCryptoJs.DecryptedRoomEvent;
|
||||
);
|
||||
|
||||
// Success. We can remove the event from the pending list, if
|
||||
// that hasn't already happened.
|
||||
this.removeEventFromPendingList(event);
|
||||
|
||||
return {
|
||||
clearEvent: JSON.parse(res.event),
|
||||
claimedEd25519Key: res.senderClaimedEd25519Key,
|
||||
senderCurve25519Key: res.senderCurve25519Key,
|
||||
forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
|
||||
};
|
||||
return rustEncryptionInfoToJsEncryptionInfo(encryptionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1428,6 +1611,61 @@ class EventDecryptor {
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyEvent(event: MatrixEvent): string {
|
||||
return JSON.stringify({
|
||||
event_id: event.getId(),
|
||||
type: event.getWireType(),
|
||||
sender: event.getSender(),
|
||||
state_key: event.getStateKey(),
|
||||
content: event.getWireContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
});
|
||||
}
|
||||
|
||||
function rustEncryptionInfoToJsEncryptionInfo(
|
||||
encryptionInfo: RustSdkCryptoJs.EncryptionInfo | undefined,
|
||||
): EventEncryptionInfo | null {
|
||||
if (encryptionInfo === undefined) {
|
||||
// not decrypted here
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: use strict shield semantics.
|
||||
const shieldState = encryptionInfo.shieldState(false);
|
||||
|
||||
let shieldColour: EventShieldColour;
|
||||
switch (shieldState.color) {
|
||||
case RustSdkCryptoJs.ShieldColor.Grey:
|
||||
shieldColour = EventShieldColour.GREY;
|
||||
break;
|
||||
case RustSdkCryptoJs.ShieldColor.None:
|
||||
shieldColour = EventShieldColour.NONE;
|
||||
break;
|
||||
default:
|
||||
shieldColour = EventShieldColour.RED;
|
||||
}
|
||||
|
||||
let shieldReason: EventShieldReason | null;
|
||||
if (shieldState.message === null) {
|
||||
shieldReason = null;
|
||||
} else if (shieldState.message === "Encrypted by an unverified user.") {
|
||||
shieldReason = EventShieldReason.UNVERIFIED_IDENTITY;
|
||||
} else if (shieldState.message === "Encrypted by a device not verified by its owner.") {
|
||||
shieldReason = EventShieldReason.UNSIGNED_DEVICE;
|
||||
} else if (
|
||||
shieldState.message === "The authenticity of this encrypted message can't be guaranteed on this device."
|
||||
) {
|
||||
shieldReason = EventShieldReason.AUTHENTICITY_NOT_GUARANTEED;
|
||||
} else if (shieldState.message === "Encrypted by an unknown or deleted device.") {
|
||||
shieldReason = EventShieldReason.UNKNOWN_DEVICE;
|
||||
} else {
|
||||
logger.warn(`Unknown shield state message '${shieldState.message}'`);
|
||||
shieldReason = EventShieldReason.UNKNOWN;
|
||||
}
|
||||
|
||||
return { shieldColour, shieldReason };
|
||||
}
|
||||
|
||||
type RustCryptoEvents =
|
||||
| CryptoEvent.VerificationRequestReceived
|
||||
| CryptoEvent.UserTrustStatusChanged
|
||||
@@ -1440,7 +1678,7 @@ type RustCryptoEventMap = {
|
||||
[CryptoEvent.VerificationRequestReceived]: (request: VerificationRequest) => void;
|
||||
|
||||
/**
|
||||
* Fires when the cross signing keys are imported during {@link CryptoApi#bootstrapCrossSigning}
|
||||
* Fires when the trust status of a user changes.
|
||||
*/
|
||||
[CryptoEvent.UserTrustStatusChanged]: (userId: string, userTrustLevel: UserVerificationStatus) => void;
|
||||
} & RustBackupCryptoEventMap;
|
||||
|
||||
+63
-1
@@ -99,6 +99,10 @@ export interface IInviteState {
|
||||
events: IStrippedState[];
|
||||
}
|
||||
|
||||
export interface IKnockState {
|
||||
events: IStrippedState[];
|
||||
}
|
||||
|
||||
export interface IInvitedRoom {
|
||||
invite_state: IInviteState;
|
||||
}
|
||||
@@ -109,10 +113,15 @@ export interface ILeftRoom {
|
||||
account_data: IAccountData;
|
||||
}
|
||||
|
||||
export interface IKnockedRoom {
|
||||
knock_state: IKnockState;
|
||||
}
|
||||
|
||||
export interface IRooms {
|
||||
[Category.Join]: Record<string, IJoinedRoom>;
|
||||
[Category.Invite]: Record<string, IInvitedRoom>;
|
||||
[Category.Leave]: Record<string, ILeftRoom>;
|
||||
[Category.Knock]: Record<string, IKnockedRoom>;
|
||||
}
|
||||
|
||||
interface IPresence {
|
||||
@@ -156,6 +165,7 @@ export enum Category {
|
||||
Invite = "invite",
|
||||
Leave = "leave",
|
||||
Join = "join",
|
||||
Knock = "knock",
|
||||
}
|
||||
|
||||
interface IRoom {
|
||||
@@ -196,6 +206,7 @@ function isTaggedEvent(event: IRoomEvent): event is TaggedEvent {
|
||||
export class SyncAccumulator {
|
||||
private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object
|
||||
private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... }
|
||||
private knockRooms: Record<string, IKnockedRoom> = {}; // $roomId: { ... sync 'knock' json data ... }
|
||||
private joinRooms: { [roomId: string]: IRoom } = {};
|
||||
// the /sync token which corresponds to the last time rooms were
|
||||
// accumulated. We remember this so that any caller can obtain a
|
||||
@@ -247,11 +258,17 @@ export class SyncAccumulator {
|
||||
this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase);
|
||||
});
|
||||
}
|
||||
if (syncResponse.rooms.knock) {
|
||||
Object.keys(syncResponse.rooms.knock).forEach((roomId) => {
|
||||
this.accumulateRoom(roomId, Category.Knock, syncResponse.rooms.knock[roomId], fromDatabase);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void;
|
||||
private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void;
|
||||
private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void;
|
||||
private accumulateRoom(roomId: string, category: Category.Knock, data: IKnockedRoom, fromDatabase: boolean): void;
|
||||
private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void {
|
||||
// Valid /sync state transitions
|
||||
// +--------+ <======+ 1: Accept an invite
|
||||
@@ -266,9 +283,17 @@ export class SyncAccumulator {
|
||||
// * equivalent to "no state"
|
||||
switch (category) {
|
||||
case Category.Invite: // (5)
|
||||
if (this.knockRooms[roomId]) {
|
||||
// was previously knock, now invite, need to delete knock state
|
||||
delete this.knockRooms[roomId];
|
||||
}
|
||||
this.accumulateInviteState(roomId, data as IInvitedRoom);
|
||||
break;
|
||||
|
||||
case Category.Knock:
|
||||
this.accumulateKnockState(roomId, data as IKnockedRoom);
|
||||
break;
|
||||
|
||||
case Category.Join:
|
||||
if (this.inviteRooms[roomId]) {
|
||||
// (1)
|
||||
@@ -282,7 +307,10 @@ export class SyncAccumulator {
|
||||
break;
|
||||
|
||||
case Category.Leave:
|
||||
if (this.inviteRooms[roomId]) {
|
||||
if (this.knockRooms[roomId]) {
|
||||
// delete knock state on leave
|
||||
delete this.knockRooms[roomId];
|
||||
} else if (this.inviteRooms[roomId]) {
|
||||
// (4)
|
||||
delete this.inviteRooms[roomId];
|
||||
} else {
|
||||
@@ -326,6 +354,36 @@ export class SyncAccumulator {
|
||||
});
|
||||
}
|
||||
|
||||
private accumulateKnockState(roomId: string, data: IKnockedRoom): void {
|
||||
if (!data.knock_state || !data.knock_state.events) {
|
||||
// no new data
|
||||
return;
|
||||
}
|
||||
if (!this.knockRooms[roomId]) {
|
||||
this.knockRooms[roomId] = {
|
||||
knock_state: data.knock_state,
|
||||
};
|
||||
return;
|
||||
}
|
||||
// accumulate extra keys
|
||||
// clobber based on event type / state key
|
||||
// We expect knock_state to be small, so just loop over the events
|
||||
const currentData = this.knockRooms[roomId];
|
||||
data.knock_state.events.forEach((e) => {
|
||||
let hasAdded = false;
|
||||
for (let i = 0; i < currentData.knock_state.events.length; i++) {
|
||||
const current = currentData.knock_state.events[i];
|
||||
if (current.type === e.type && current.state_key == e.state_key) {
|
||||
currentData.knock_state.events[i] = e; // update
|
||||
hasAdded = true;
|
||||
}
|
||||
}
|
||||
if (!hasAdded) {
|
||||
currentData.knock_state.events.push(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate timeline and state events in a room.
|
||||
private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
|
||||
// We expect this function to be called a lot (every /sync) so we want
|
||||
@@ -485,6 +543,7 @@ export class SyncAccumulator {
|
||||
const data: IRooms = {
|
||||
join: {},
|
||||
invite: {},
|
||||
knock: {},
|
||||
// always empty. This is set by /sync when a room was previously
|
||||
// in 'invite' or 'join'. On fresh startup, the client won't know
|
||||
// about any previous room being in 'invite' or 'join' so we can
|
||||
@@ -501,6 +560,9 @@ export class SyncAccumulator {
|
||||
Object.keys(this.inviteRooms).forEach((roomId) => {
|
||||
data.invite[roomId] = this.inviteRooms[roomId];
|
||||
});
|
||||
Object.keys(this.knockRooms).forEach((roomId) => {
|
||||
data.knock[roomId] = this.knockRooms[roomId];
|
||||
});
|
||||
Object.keys(this.joinRooms).forEach((roomId) => {
|
||||
const roomData = this.joinRooms[roomId];
|
||||
const roomJson: IJoinedRoom = {
|
||||
|
||||
+26
-1
@@ -40,6 +40,7 @@ import {
|
||||
IInviteState,
|
||||
IJoinedRoom,
|
||||
ILeftRoom,
|
||||
IKnockedRoom,
|
||||
IMinimalEvent,
|
||||
IRoomEvent,
|
||||
IStateEvent,
|
||||
@@ -1240,6 +1241,7 @@ export class SyncApi {
|
||||
let inviteRooms: WrappedRoom<IInvitedRoom>[] = [];
|
||||
let joinRooms: WrappedRoom<IJoinedRoom>[] = [];
|
||||
let leaveRooms: WrappedRoom<ILeftRoom>[] = [];
|
||||
let knockRooms: WrappedRoom<IKnockedRoom>[] = [];
|
||||
|
||||
if (data.rooms) {
|
||||
if (data.rooms.invite) {
|
||||
@@ -1251,6 +1253,9 @@ export class SyncApi {
|
||||
if (data.rooms.leave) {
|
||||
leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
|
||||
}
|
||||
if (data.rooms.knock) {
|
||||
knockRooms = this.mapSyncResponseToRoomArray(data.rooms.knock);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifEvents = [];
|
||||
@@ -1511,6 +1516,26 @@ export class SyncApi {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle knocks
|
||||
await promiseMapSeries(knockRooms, async (knockObj) => {
|
||||
const room = knockObj.room;
|
||||
const stateEvents = this.mapSyncEventsFormat(knockObj.knock_state, room);
|
||||
|
||||
await this.injectRoomEvents(room, stateEvents);
|
||||
|
||||
if (knockObj.isBrandNewRoom) {
|
||||
room.recalculate();
|
||||
client.store.storeRoom(room);
|
||||
client.emit(ClientEvent.Room, room);
|
||||
} else {
|
||||
// Update room state for knock->leave->knock cycles
|
||||
room.recalculate();
|
||||
}
|
||||
stateEvents.forEach(function (e) {
|
||||
client.emit(ClientEvent.Event, e);
|
||||
});
|
||||
});
|
||||
|
||||
// update the notification timeline, if appropriate.
|
||||
// we only do this for live events, as otherwise we can't order them sanely
|
||||
// in the timeline relative to ones paginated in by /notifications.
|
||||
@@ -1629,7 +1654,7 @@ export class SyncApi {
|
||||
);
|
||||
}
|
||||
|
||||
private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom>(
|
||||
private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom | IKnockedRoom>(
|
||||
obj: Record<string, T>,
|
||||
): Array<WrappedRoom<T>> {
|
||||
// Maps { roomid: {stuff}, roomid: {stuff} }
|
||||
|
||||
+43
-6
@@ -170,6 +170,8 @@ export interface IGroupCallRoomState {
|
||||
// TODO: Specify data-channels
|
||||
"dataChannelsEnabled"?: boolean;
|
||||
"dataChannelOptions"?: IGroupCallDataChannelOptions;
|
||||
|
||||
"io.element.livekit_service_url"?: string;
|
||||
}
|
||||
|
||||
export interface IGroupCallRoomMemberFeed {
|
||||
@@ -250,6 +252,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
private initWithAudioMuted = false;
|
||||
private initWithVideoMuted = false;
|
||||
private initCallFeedPromise?: Promise<void>;
|
||||
private _livekitServiceURL?: string;
|
||||
|
||||
private stats: GroupCallStats | undefined;
|
||||
/**
|
||||
@@ -268,10 +271,16 @@ export class GroupCall extends TypedEventEmitter<
|
||||
private dataChannelsEnabled?: boolean,
|
||||
private dataChannelOptions?: IGroupCallDataChannelOptions,
|
||||
isCallWithoutVideoAndAudio?: boolean,
|
||||
// this tells the js-sdk not to actually establish any calls to exchange media and just to
|
||||
// create the group call signaling events, with the intention that the actual media will be
|
||||
// handled using livekit. The js-sdk doesn't contain any code to do the actual livekit call though.
|
||||
private useLivekit = false,
|
||||
livekitServiceURL?: string,
|
||||
) {
|
||||
super();
|
||||
this.reEmitter = new ReEmitter(this);
|
||||
this.groupCallId = groupCallId ?? genCallID();
|
||||
this._livekitServiceURL = livekitServiceURL;
|
||||
this.creationTs =
|
||||
room.currentState.getStateEvents(EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null;
|
||||
this.updateParticipants();
|
||||
@@ -320,6 +329,12 @@ export class GroupCall extends TypedEventEmitter<
|
||||
this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this);
|
||||
this.client.emit(GroupCallEventHandlerEvent.Outgoing, this);
|
||||
|
||||
await this.sendCallStateEvent();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private async sendCallStateEvent(): Promise<void> {
|
||||
const groupCallState: IGroupCallRoomState = {
|
||||
"m.intent": this.intent,
|
||||
"m.type": this.type,
|
||||
@@ -328,10 +343,20 @@ export class GroupCall extends TypedEventEmitter<
|
||||
"dataChannelsEnabled": this.dataChannelsEnabled,
|
||||
"dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined,
|
||||
};
|
||||
if (this.livekitServiceURL) {
|
||||
groupCallState["io.element.livekit_service_url"] = this.livekitServiceURL;
|
||||
}
|
||||
|
||||
await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallPrefix, groupCallState, this.groupCallId);
|
||||
}
|
||||
|
||||
return this;
|
||||
public get livekitServiceURL(): string | undefined {
|
||||
return this._livekitServiceURL;
|
||||
}
|
||||
|
||||
public updateLivekitServiceURL(newURL: string): Promise<void> {
|
||||
this._livekitServiceURL = newURL;
|
||||
return this.sendCallStateEvent();
|
||||
}
|
||||
|
||||
private _state = GroupCallState.LocalCallFeedUninitialized;
|
||||
@@ -442,6 +467,11 @@ export class GroupCall extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
public async initLocalCallFeed(): Promise<void> {
|
||||
if (this.useLivekit) {
|
||||
logger.info("Livekit group call: not starting local call feed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state !== GroupCallState.LocalCallFeedUninitialized) {
|
||||
throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`);
|
||||
}
|
||||
@@ -537,11 +567,13 @@ export class GroupCall extends TypedEventEmitter<
|
||||
this.onIncomingCall(call);
|
||||
}
|
||||
|
||||
this.retryCallLoopInterval = setInterval(this.onRetryCallLoop, this.retryCallInterval);
|
||||
if (!this.useLivekit) {
|
||||
this.retryCallLoopInterval = setInterval(this.onRetryCallLoop, this.retryCallInterval);
|
||||
|
||||
this.activeSpeaker = undefined;
|
||||
this.onActiveSpeakerLoop();
|
||||
this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval);
|
||||
this.activeSpeaker = undefined;
|
||||
this.onActiveSpeakerLoop();
|
||||
this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
@@ -923,6 +955,11 @@ export class GroupCall extends TypedEventEmitter<
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.useLivekit) {
|
||||
logger.info("Received incoming call whilst in signaling-only mode! Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
|
||||
const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
|
||||
|
||||
@@ -1629,7 +1666,7 @@ export class GroupCall extends TypedEventEmitter<
|
||||
}
|
||||
});
|
||||
|
||||
if (this.state === GroupCallState.Entered) this.placeOutgoingCalls();
|
||||
if (this.state === GroupCallState.Entered && !this.useLivekit) this.placeOutgoingCalls();
|
||||
|
||||
// Update the participants stored in the stats object
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export class GroupCallEventHandler {
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.removeListener(ClientEvent.Room, this.onRoomsChanged);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateChanged);
|
||||
}
|
||||
|
||||
@@ -189,6 +190,8 @@ export class GroupCallEventHandler {
|
||||
content?.dataChannelsEnabled || this.client.isVoipWithNoMediaAllowed,
|
||||
dataChannelOptions,
|
||||
this.client.isVoipWithNoMediaAllowed,
|
||||
this.client.useLivekitForGroupCalls,
|
||||
content["io.element.livekit_service_url"],
|
||||
);
|
||||
|
||||
this.groupCalls.set(room.roomId, groupCall);
|
||||
|
||||
Reference in New Issue
Block a user