diff --git a/spec/TestClient.js b/spec/TestClient.js deleted file mode 100644 index 7b2474c15ca..00000000000 --- a/spec/TestClient.js +++ /dev/null @@ -1,238 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// load olm before the sdk if possible -import './olm-loader'; - -import MockHttpBackend from 'matrix-mock-request'; - -import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; -import { logger } from '../src/logger'; -import { WebStorageSessionStore } from "../src/store/session/webstorage"; -import { syncPromise } from "./test-utils/test-utils"; -import { createClient } from "../src/matrix"; -import { MockStorageApi } from "./MockStorageApi"; - -/** - * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient - * - * @constructor - * @param {string} userId - * @param {string} deviceId - * @param {string} accessToken - * - * @param {WebStorage=} sessionStoreBackend a web storage object to use for the - * session store. If undefined, we will create a MockStorageApi. - * @param {object} options additional options to pass to the client - */ -export function TestClient( - userId, deviceId, accessToken, sessionStoreBackend, options, -) { - this.userId = userId; - this.deviceId = deviceId; - - if (sessionStoreBackend === undefined) { - sessionStoreBackend = new MockStorageApi(); - } - const sessionStore = new WebStorageSessionStore(sessionStoreBackend); - - this.httpBackend = new MockHttpBackend(); - - options = Object.assign({ - baseUrl: "http://" + userId + ".test.server", - userId: userId, - accessToken: accessToken, - deviceId: deviceId, - sessionStore: sessionStore, - request: this.httpBackend.requestFn, - }, options); - if (!options.cryptoStore) { - // expose this so the tests can get to it - this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); - options.cryptoStore = this.cryptoStore; - } - this.client = createClient(options); - - this.deviceKeys = null; - this.oneTimeKeys = {}; - this.callEventHandler = { - calls: new Map(), - }; -} - -TestClient.prototype.toString = function() { - return 'TestClient[' + this.userId + ']'; -}; - -/** - * start the client, and wait for it to initialise. - * - * @return {Promise} - */ -TestClient.prototype.start = function() { - logger.log(this + ': starting'); - this.httpBackend.when("GET", "/versions").respond(200, {}); - this.httpBackend.when("GET", "/pushrules").respond(200, {}); - this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - this.expectDeviceKeyUpload(); - - // we let the client do a very basic initial sync, which it needs before - // it will upload one-time keys. - this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); - - this.client.startClient({ - // set this so that we can get hold of failed events - pendingEventOrdering: 'detached', - }); - - return Promise.all([ - this.httpBackend.flushAllExpected(), - syncPromise(this.client), - ]).then(() => { - logger.log(this + ': started'); - }); -}; - -/** - * stop the client - * @return {Promise} Resolves once the mock http backend has finished all pending flushes - */ -TestClient.prototype.stop = function() { - this.client.stopClient(); - return this.httpBackend.stop(); -}; - -/** - * Set up expectations that the client will upload device keys. - */ -TestClient.prototype.expectDeviceKeyUpload = function() { - const self = this; - this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) { - expect(content.one_time_keys).toBe(undefined); - expect(content.device_keys).toBeTruthy(); - - logger.log(self + ': received device keys'); - // we expect this to happen before any one-time keys are uploaded. - expect(Object.keys(self.oneTimeKeys).length).toEqual(0); - - self.deviceKeys = content.device_keys; - return { one_time_key_counts: { signed_curve25519: 0 } }; - }); -}; - -/** - * If one-time keys have already been uploaded, return them. Otherwise, - * set up an expectation that the keys will be uploaded, and wait for - * that to happen. - * - * @returns {Promise} for the one-time keys - */ -TestClient.prototype.awaitOneTimeKeyUpload = function() { - if (Object.keys(this.oneTimeKeys).length != 0) { - // already got one-time keys - return Promise.resolve(this.oneTimeKeys); - } - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBe(undefined); - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBeTruthy(); - expect(content.one_time_keys).not.toEqual({}); - logger.log('%s: received %i one-time keys', this, - Object.keys(content.one_time_keys).length); - this.oneTimeKeys = content.one_time_keys; - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - // this can take ages - return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { - expect(flushed).toEqual(2); - return this.oneTimeKeys; - }); -}; - -/** - * Set up expectations that the client will query device keys. - * - * We check that the query contains each of the users in `response`. - * - * @param {Object} response response to the query. - */ -TestClient.prototype.expectKeyQuery = function(response) { - this.httpBackend.when('POST', '/keys/query').respond( - 200, (path, content) => { - Object.keys(response.device_keys).forEach((userId) => { - expect(content.device_keys[userId]).toEqual( - [], - "Expected key query for " + userId + ", got " + - Object.keys(content.device_keys), - ); - }); - return response; - }); -}; - -/** - * get the uploaded curve25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getDeviceKey = function() { - const keyId = 'curve25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * get the uploaded ed25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getSigningKey = function() { - const keyId = 'ed25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * flush a single /sync request, and wait for the syncing event - * - * @returns {Promise} promise which completes once the sync has been flushed - */ -TestClient.prototype.flushSync = function() { - logger.log(`${this}: flushSync`); - return Promise.all([ - this.httpBackend.flush('/sync', 1), - syncPromise(this.client), - ]).then(() => { - logger.log(`${this}: flushSync completed`); - }); -}; - -TestClient.prototype.isFallbackICEServerAllowed = function() { - return true; -}; diff --git a/spec/TestClient.ts b/spec/TestClient.ts new file mode 100644 index 00000000000..42eb5fa28da --- /dev/null +++ b/spec/TestClient.ts @@ -0,0 +1,239 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// load olm before the sdk if possible +import './olm-loader'; + +import MockHttpBackend from 'matrix-mock-request'; + +import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; +import { logger } from '../src/logger'; +import { WebStorageSessionStore } from "../src/store/session/webstorage"; +import { syncPromise } from "./test-utils/test-utils"; +import { createClient } from "../src/matrix"; +import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; +import { MockStorageApi } from "./MockStorageApi"; +import { encodeUri } from "../src/utils"; +import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; +import { IKeyBackupSession } from "../src/crypto/keybackup"; + +/** + * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient + */ +export class TestClient { + public readonly httpBackend: MockHttpBackend; + public readonly client: MatrixClient; + private deviceKeys: IDeviceKeys; + private oneTimeKeys: Record; + + constructor( + public readonly userId?: string, + public readonly deviceId?: string, + accessToken?: string, + sessionStoreBackend?: Storage, + options?: Partial, + ) { + if (sessionStoreBackend === undefined) { + sessionStoreBackend = new MockStorageApi(); + } + const sessionStore = new WebStorageSessionStore(sessionStoreBackend); + + this.httpBackend = new MockHttpBackend(); + + const fullOptions: ICreateClientOpts = { + baseUrl: "http://" + userId + ".test.server", + userId: userId, + accessToken: accessToken, + deviceId: deviceId, + sessionStore: sessionStore, + request: this.httpBackend.requestFn, + ...options, + }; + if (!fullOptions.cryptoStore) { + // expose this so the tests can get to it + fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + } + this.client = createClient(fullOptions); + + this.deviceKeys = null; + this.oneTimeKeys = {}; + } + + public toString(): string { + return 'TestClient[' + this.userId + ']'; + } + + /** + * start the client, and wait for it to initialise. + */ + public start(): Promise { + logger.log(this + ': starting'); + this.httpBackend.when("GET", "/versions").respond(200, {}); + this.httpBackend.when("GET", "/pushrules").respond(200, {}); + this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + this.expectDeviceKeyUpload(); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); + + this.client.startClient({ + // set this so that we can get hold of failed events + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + return Promise.all([ + this.httpBackend.flushAllExpected(), + syncPromise(this.client), + ]).then(() => { + logger.log(this + ': started'); + }); + } + + /** + * stop the client + * @return {Promise} Resolves once the mock http backend has finished all pending flushes + */ + public stop(): Promise { + this.client.stopClient(); + return this.httpBackend.stop(); + } + + /** + * Set up expectations that the client will upload device keys. + */ + public expectDeviceKeyUpload() { + this.httpBackend.when("POST", "/keys/upload").respond(200, (path, content) => { + expect(content.one_time_keys).toBe(undefined); + expect(content.device_keys).toBeTruthy(); + + logger.log(this + ': received device keys'); + // we expect this to happen before any one-time keys are uploaded. + expect(Object.keys(this.oneTimeKeys).length).toEqual(0); + + this.deviceKeys = content.device_keys; + return { one_time_key_counts: { signed_curve25519: 0 } }; + }); + } + + /** + * If one-time keys have already been uploaded, return them. Otherwise, + * set up an expectation that the keys will be uploaded, and wait for + * that to happen. + * + * @returns {Promise} for the one-time keys + */ + public awaitOneTimeKeyUpload(): Promise> { + if (Object.keys(this.oneTimeKeys).length != 0) { + // already got one-time keys + return Promise.resolve(this.oneTimeKeys); + } + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBe(undefined); + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBeTruthy(); + expect(content.one_time_keys).not.toEqual({}); + logger.log('%s: received %i one-time keys', this, + Object.keys(content.one_time_keys).length); + this.oneTimeKeys = content.one_time_keys; + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + // this can take ages + return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { + expect(flushed).toEqual(2); + return this.oneTimeKeys; + }); + } + + /** + * Set up expectations that the client will query device keys. + * + * We check that the query contains each of the users in `response`. + * + * @param {Object} response response to the query. + */ + public expectKeyQuery(response: IDownloadKeyResult) { + this.httpBackend.when('POST', '/keys/query').respond( + 200, (path, content) => { + Object.keys(response.device_keys).forEach((userId) => { + expect(content.device_keys[userId]).toEqual([]); + }); + return response; + }); + } + + /** + * Set up expectations that the client will query key backups for a particular session + */ + public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) { + this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + })).respond(status, response); + } + + /** + * get the uploaded curve25519 device key + * + * @return {string} base64 device key + */ + public getDeviceKey(): string { + const keyId = 'curve25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * get the uploaded ed25519 device key + * + * @return {string} base64 device key + */ + public getSigningKey(): string { + const keyId = 'ed25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * flush a single /sync request, and wait for the syncing event + */ + public flushSync(): Promise { + logger.log(`${this}: flushSync`); + return Promise.all([ + this.httpBackend.flush('/sync', 1), + syncPromise(this.client), + ]).then(() => { + logger.log(`${this}: flushSync completed`); + }); + } + + public isFallbackICEServerAllowed(): boolean { + return true; + } +} diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 954b62a76f6..a886ccab7a1 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -161,7 +161,7 @@ function aliDownloadsKeys() { return Promise.all([p1, p2]).then(() => { return aliTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const devices = data.devices[bobUserId]; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].verified). diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index 6f74e4188b8..31354b89a65 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -1,10 +1,26 @@ -import { EventStatus, RoomEvent } from "../../src/matrix"; +/* +Copyright 2022 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 { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; describe("MatrixClient retrying", function() { - let client: TestClient = null; + let client: MatrixClient = null; let httpBackend: TestClient["httpBackend"] = null; let scheduler; const userId = "@alice:localhost"; diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts new file mode 100644 index 00000000000..5fa6755192f --- /dev/null +++ b/spec/integ/megolm-backup.spec.ts @@ -0,0 +1,165 @@ +/* +Copyright 2022 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 { Account } from "@matrix-org/olm"; + +import { logger } from "../../src/logger"; +import { decodeRecoveryKey } from "../../src/crypto/recoverykey"; +import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup"; +import { TestClient } from "../TestClient"; +import { IEvent } from "../../src"; +import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; + +const ROOM_ID = '!ROOM:ID'; + +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; + +const ENCRYPTED_EVENT: Partial = { + 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 CURVE25519_BACKUP_INFO: IKeyBackupInfo = { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + version: "1", + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, +}; + +const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d"; + +/** + * start an Olm session with a given recipient + */ +function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise { + return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => { + const otkId = Object.keys(keys)[0]; + const otk = keys[otkId]; + + const session = new global.Olm.Session(); + session.create_outbound( + olmAccount, recipientTestClient.getDeviceKey(), otk.key, + ); + return session; + }); +} + +describe("megolm key backups", function() { + if (!global.Olm) { + logger.warn('not running megolm tests: Olm not present'); + return; + } + const Olm = global.Olm; + + let testOlmAccount: Account; + let aliceTestClient: TestClient; + + beforeAll(function() { + return Olm.init(); + }); + + beforeEach(async function() { + aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + await aliceTestClient.client.initCrypto(); + aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; + }); + + afterEach(function() { + return aliceTestClient.stop(); + }); + + it("Alice checks key backups when receiving a message she can't decrypt", function() { + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [ENCRYPTED_EVENT], + }, + }; + + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then(() => { + const privkey = decodeRecoveryKey(RECOVERY_KEY); + return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey); + }).then(() => { + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient.expectKeyBackupQuery( + ROOM_ID, + SESSION_ID, + 200, + CURVE25519_KEY_BACKUP_DATA, + ); + return aliceTestClient.httpBackend.flushAllExpected(); + }).then(function(): Promise { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + + if (event.getContent()) { + return Promise.resolve(event); + } + + return new Promise((resolve, reject) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + }).then((event) => { + expect(event.getContent()).toEqual('testytest'); + }); + }); +}); diff --git a/spec/unit/webrtc/callEventHandler.spec.ts b/spec/unit/webrtc/callEventHandler.spec.ts index d60ae299791..a8103f0d5f2 100644 --- a/spec/unit/webrtc/callEventHandler.spec.ts +++ b/spec/unit/webrtc/callEventHandler.spec.ts @@ -15,7 +15,16 @@ limitations under the License. */ import { TestClient } from '../../TestClient'; -import { ClientEvent, EventType, MatrixEvent, RoomEvent } from "../../../src"; +import { + ClientEvent, + EventTimeline, + EventTimelineSet, + EventType, + IRoomTimelineData, + MatrixEvent, + Room, + RoomEvent, +} from "../../../src"; import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler"; import { SyncState } from "../../../src/sync"; @@ -23,6 +32,8 @@ describe("callEventHandler", () => { it("should ignore a call if invite & hangup come within a single sync", () => { const testClient = new TestClient(); const client = testClient.client; + const room = new Room("!room:id", client, "@user:id"); + const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) }; client.callEventHandler = new CallEventHandler(client); client.callEventHandler.start(); @@ -33,7 +44,7 @@ describe("callEventHandler", () => { call_id: "123", }, }); - client.emit(RoomEvent.Timeline, callInvite); + client.emit(RoomEvent.Timeline, callInvite, room, false, false, timelineData); const callHangup = new MatrixEvent({ type: EventType.CallHangup, @@ -41,13 +52,13 @@ describe("callEventHandler", () => { call_id: "123", }, }); - client.emit(RoomEvent.Timeline, callHangup); + client.emit(RoomEvent.Timeline, callHangup, room, false, false, timelineData); const incomingCallEmitted = jest.fn(); client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted); client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing); - client.emit(ClientEvent.Sync); + client.emit(ClientEvent.Sync, SyncState.Syncing); expect(incomingCallEmitted).not.toHaveBeenCalled(); }); diff --git a/src/client.ts b/src/client.ts index 1e97cc80325..04555b7c16b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3100,6 +3100,18 @@ export class MatrixClient extends TypedEventEmitter { + const privKey = await this.crypto.backupManager.getKey(); + return this.restoreKeyBackup( + privKey, targetRoomId, targetSessionId, backupInfo, opts, + ); + } + private async restoreKeyBackup( privKey: ArrayLike, targetRoomId: undefined, diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index f960dd4f15e..b7bb1165a6e 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -1298,7 +1298,9 @@ class MegolmDecryption extends DecryptionAlgorithm { if (res === null) { // We've got a message for a session we don't have. - // + // try and get the missing key from the backup first + this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); + // (XXX: We might actually have received this key since we started // decrypting, in which case we'll have scheduled a retry, and this // request will be redundant. We could probably check to see if the diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 9b17c84c5e4..94ec3c62445 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -35,6 +35,7 @@ import { UnstableValue } from "../NamespacedValue"; import { CryptoEvent, IMegolmSessionData } from "./index"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; +const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms type AuthData = IKeyBackupInfo["auth_data"]; @@ -111,6 +112,8 @@ export class BackupManager { public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? private sendingBackups: boolean; // Are we currently sending backups? + private sessionLastCheckAttemptedTime: Record = {}; // When did we last try to check the server for a given session id? + constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { this.checkedForBackup = false; this.sendingBackups = false; @@ -282,6 +285,26 @@ export class BackupManager { return this.checkAndStart(); } + /** + * 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 | undefined, + targetSessionId: string | undefined, + ): Promise { + if (!this.backupInfo) { return; } + + const now = new Date().getTime(); + if ( + !this.sessionLastCheckAttemptedTime[targetSessionId] + || now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT + ) { + this.sessionLastCheckAttemptedTime[targetSessionId] = now; + await this.baseApis.restoreKeyBackupWithBackupManager(targetRoomId, targetSessionId, this.backupInfo, {}); + } + } + /** * Check if the given backup info is trusted. * diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 1b7ae52405d..16135163280 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -15,17 +15,19 @@ limitations under the License. */ import { ISigned } from "../@types/signed"; +import { IEncryptedPayload } from "./aes"; + +export interface Curve25519SessionData { + ciphertext: string; + ephemeral: string; + mac: string; +} export interface IKeyBackupSession { first_message_index: number; // eslint-disable-line camelcase forwarded_count: number; // eslint-disable-line camelcase is_verified: boolean; // eslint-disable-line camelcase - session_data: { // eslint-disable-line camelcase - ciphertext: string; - ephemeral: string; - mac: string; - iv: string; - }; + session_data: Curve25519SessionData | IEncryptedPayload; // eslint-disable-line camelcase } export interface IKeyBackupRoomSessions {