From ff53557957d46ba9d74e0a4df962f46fa192a14b Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 29 Sep 2023 18:26:24 +0200 Subject: [PATCH] Clean up integ tests for incoming user verification (#3758) Move the tests into verification.spec.ts, enable for both stacks, and other cleanups. --- spec/integ/crypto/crypto.spec.ts | 417 +------------------------ spec/integ/crypto/olm-utils.ts | 255 +++++++++++++++ spec/integ/crypto/verification.spec.ts | 194 +++++++++++- 3 files changed, 461 insertions(+), 405 deletions(-) create mode 100644 spec/integ/crypto/olm-utils.ts diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 66de6358b..55058bf07 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -22,23 +22,21 @@ import { IDBFactory } from "fake-indexeddb"; import { MockResponse, MockResponseFunction } from "fetch-mock"; import Olm from "@matrix-org/olm"; -import type { IDeviceKeys } from "../../../src/@types/crypto"; import * as testUtils from "../../test-utils/test-utils"; import { CRYPTO_BACKENDS, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils"; +import * as testData from "../../test-utils/test-data"; import { BOB_SIGNED_CROSS_SIGNING_KEYS_DATA, BOB_SIGNED_TEST_DEVICE_DATA, BOB_TEST_USER_ID, SIGNED_CROSS_SIGNING_KEYS_DATA, SIGNED_TEST_DEVICE_DATA, - TEST_ROOM_ID, TEST_ROOM_ID as ROOM_ID, TEST_USER_ID, } from "../../test-utils/test-data"; import { TestClient } from "../../TestClient"; import { logger } from "../../../src/logger"; import { - Category, ClientEvent, createClient, CryptoEvent, @@ -47,7 +45,6 @@ import { IDownloadKeyResult, IEvent, IndexedDBCryptoStore, - IRoomEvent, IStartClientOpts, MatrixClient, MatrixEvent, @@ -58,8 +55,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 { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder"; import { escapeRegExp } from "../../../src/utils"; import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter"; @@ -74,6 +70,16 @@ import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/cr import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { DecryptionError } from "../../../src/crypto/algorithms"; import { IKeyBackup } from "../../../src/crypto/backup"; +import { + createOlmSession, + createOlmAccount, + encryptGroupSessionKey, + encryptMegolmEvent, + encryptMegolmEventRawPlainText, + encryptOlmEvent, + establishOlmSession, + getTestOlmAccountKeys, +} from "./olm-utils"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -82,197 +88,6 @@ afterEach(() => { indexedDB = new IDBFactory(); }); -// start an Olm session with a given recipient -async function createOlmSession(olmAccount: Olm.Account, recipientTestClient: IE2EKeyReceiver): Promise { - const keys = await recipientTestClient.awaitOneTimeKeyUpload(); - 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; -} - -// IToDeviceEvent isn't exported by src/sync-accumulator.ts -interface ToDeviceEvent { - content: IContent; - sender: string; - type: string; -} - -/** encrypt an event with an existing olm session */ -function encryptOlmEvent(opts: { - /** the sender's user id */ - sender?: string; - /** the sender's curve25519 key */ - senderKey: string; - /** the sender's ed25519 key */ - senderSigningKey: string; - /** the olm session to use for encryption */ - p2pSession: Olm.Session; - /** the recipient's user id */ - recipient: string; - /** the recipient's curve25519 key */ - recipientCurve25519Key: string; - /** the recipient's ed25519 key */ - recipientEd25519Key: string; - /** the payload of the message */ - plaincontent?: object; - /** the event type of the payload */ - plaintype?: string; -}): ToDeviceEvent { - expect(opts.senderKey).toBeTruthy(); - expect(opts.p2pSession).toBeTruthy(); - expect(opts.recipient).toBeTruthy(); - - const plaintext = { - content: opts.plaincontent || {}, - recipient: opts.recipient, - recipient_keys: { - ed25519: opts.recipientEd25519Key, - }, - keys: { - ed25519: opts.senderSigningKey, - }, - sender: opts.sender || "@bob:xyz", - type: opts.plaintype || "m.test", - }; - - return { - content: { - algorithm: "m.olm.v1.curve25519-aes-sha2", - ciphertext: { - [opts.recipientCurve25519Key]: opts.p2pSession.encrypt(JSON.stringify(plaintext)), - }, - sender_key: opts.senderKey, - }, - sender: opts.sender || "@bob:xyz", - type: "m.room.encrypted", - }; -} - -// encrypt an event with megolm -function encryptMegolmEvent(opts: { - senderKey: string; - groupSession: Olm.OutboundGroupSession; - plaintext?: Partial; - room_id?: string; -}): IEvent { - expect(opts.senderKey).toBeTruthy(); - expect(opts.groupSession).toBeTruthy(); - - const plaintext = opts.plaintext || {}; - if (!plaintext.content) { - plaintext.content = { - body: "42", - msgtype: "m.text", - }; - } - if (!plaintext.type) { - plaintext.type = "m.room.message"; - } - if (!plaintext.room_id) { - expect(opts.room_id).toBeTruthy(); - plaintext.room_id = opts.room_id; - } - return encryptMegolmEventRawPlainText({ - senderKey: opts.senderKey, - groupSession: opts.groupSession, - plaintext, - }); -} - -function encryptMegolmEventRawPlainText(opts: { - senderKey: string; - groupSession: Olm.OutboundGroupSession; - plaintext: Partial; - origin_server_ts?: number; -}): IEvent { - return { - event_id: "$test_megolm_event_" + Math.random(), - sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com", - origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000, - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)), - device_id: "testDevice", - sender_key: opts.senderKey, - session_id: opts.groupSession.session_id(), - }, - type: "m.room.encrypted", - unsigned: {}, - }; -} - -/** build an encrypted room_key event to share a group session, using an existing olm session */ -function encryptGroupSessionKey(opts: { - /** recipient's user id */ - recipient: string; - /** the recipient's curve25519 key */ - recipientCurve25519Key: string; - /** the recipient's ed25519 key */ - recipientEd25519Key: string; - /** sender's olm account */ - olmAccount: Olm.Account; - /** sender's olm session with the recipient */ - p2pSession: Olm.Session; - groupSession: Olm.OutboundGroupSession; - room_id?: string; -}): Partial { - const senderKeys = JSON.parse(opts.olmAccount.identity_keys()); - return encryptOlmEvent({ - senderKey: senderKeys.curve25519, - senderSigningKey: senderKeys.ed25519, - recipient: opts.recipient, - recipientCurve25519Key: opts.recipientCurve25519Key, - recipientEd25519Key: opts.recipientEd25519Key, - p2pSession: opts.p2pSession, - plaincontent: { - algorithm: "m.megolm.v1.aes-sha2", - room_id: opts.room_id, - session_id: opts.groupSession.session_id(), - session_key: opts.groupSession.session_key(), - }, - plaintype: "m.room_key", - }); -} - -/** - * Establish an Olm Session with the test user - * - * Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will - * establish an Olm session. - * - * @param testClient - the MatrixClient under test, which we expect to upload account keys, and to make a - * /sync request which we will respond to. - * @param keyReceiver - an IE2EKeyReceiver which will intercept the /keys/upload request from the client under test - * @param syncResponder - an ISyncResponder which will intercept /sync requests from the client under test - * @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session. - */ -async function establishOlmSession( - testClient: MatrixClient, - keyReceiver: IE2EKeyReceiver, - syncResponder: ISyncResponder, - peerOlmAccount: Olm.Account, -): Promise { - const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys()); - const p2pSession = await createOlmSession(peerOlmAccount, keyReceiver); - const olmEvent = encryptOlmEvent({ - senderKey: peerE2EKeys.curve25519, - senderSigningKey: peerE2EKeys.ed25519, - recipient: testClient.getUserId()!, - recipientCurve25519Key: keyReceiver.getDeviceKey(), - recipientEd25519Key: keyReceiver.getSigningKey(), - p2pSession: p2pSession, - }); - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - to_device: { events: [olmEvent] }, - }); - await syncPromise(testClient); - return p2pSession; -} - /** * Expect that the client shares keys with the given recipient * @@ -461,20 +276,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, * @returns The fake query response */ function getTestKeysQueryResponse(userId: string): IDownloadKeyResult { - const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); - const testDeviceKeys: IDeviceKeys = { - algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], - device_id: "DEVICE_ID", - keys: { - "curve25519:DEVICE_ID": testE2eKeys.curve25519, - "ed25519:DEVICE_ID": testE2eKeys.ed25519, - }, - user_id: userId, - }; - const j = anotherjson.stringify(testDeviceKeys); - const sig = testOlmAccount.sign(j); - testDeviceKeys.signatures = { [userId]: { "ed25519:DEVICE_ID": sig } }; - + const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, userId, "DEVICE_ID"); return { device_keys: { [userId]: { DEVICE_ID: testDeviceKeys } }, failures: {}, @@ -552,9 +354,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await initCrypto(aliceClient); // create a test olm device which we will use to communicate with alice. We use libolm to implement this. - await Olm.init(); - testOlmAccount = new Olm.Account(); - testOlmAccount.create(); + testOlmAccount = await createOlmAccount(); const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); testSenderKey = testE2eKeys.curve25519; }, @@ -2770,195 +2570,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); }); - describe("Incoming verification in a DM", () => { - beforeEach(async () => { - // anything that we don't have a specific matcher for silently returns a 404 - fetchMock.catch(404); - - keyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); - keyResponder.addKeyReceiver(TEST_USER_ID, keyReceiver); - - expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); - await startClientAndAwaitFirstSync(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - /** - * Return a verification request event from Bob - * @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest - */ - function createVerificationRequestEvent(): IRoomEvent { - return { - content: { - body: "Verification request from Bob to Alice", - from_device: "BobDevice", - methods: ["m.sas.v1"], - msgtype: "m.key.verification.request", - to: aliceClient.getUserId()!, - }, - event_id: "$143273582443PhrSn:example.org", - origin_server_ts: Date.now(), - room_id: TEST_ROOM_ID, - sender: "@bob:xyz", - type: "m.room.message", - unsigned: { - age: 1234, - }, - }; - } - - /** - * Create a to-device event - * @param groupSession - * @param p2pSession - */ - function createToDeviceEvent(groupSession: Olm.OutboundGroupSession, p2pSession: Olm.Session): Partial { - return encryptGroupSessionKey({ - recipient: aliceClient.getUserId()!, - recipientCurve25519Key: keyReceiver.getDeviceKey(), - recipientEd25519Key: keyReceiver.getSigningKey(), - olmAccount: testOlmAccount, - p2pSession: p2pSession, - groupSession: groupSession, - room_id: ROOM_ID, - }); - } - - /** - * Create and encrypt a verification request event - * @param groupSession - */ - function createEncryptedMessage(groupSession: Olm.OutboundGroupSession): IEvent { - return encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - plaintext: createVerificationRequestEvent(), - }); - } - - newBackendOnly("Verification request from Bob to Alice", async () => { - // Tell alice she is sharing a room with bob - const syncResponse = getSyncResponse(["@bob:xyz"]); - - // Add verification request from Bob to Alice in the DM between them - syncResponse.rooms[Category.Join][TEST_ROOM_ID].timeline.events.push(createVerificationRequestEvent()); - syncResponder.sendOrQueueSyncResponse(syncResponse); - // Wait for the sync response to be processed - await syncPromise(aliceClient); - - const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); - // Expect to find the verification request received during the sync - expect(request?.roomId).toBe(TEST_ROOM_ID); - expect(request?.isSelfVerification).toBe(false); - expect(request?.otherUserId).toBe("@bob:xyz"); - }); - - newBackendOnly("Verification request not found", async () => { - // Tell alice she is sharing a room with bob - syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); - // Wait for the sync response to be processed - await syncPromise(aliceClient); - - // Expect to not find any verification request - const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); - expect(request).not.toBeDefined(); - }); - - newBackendOnly("Process encrypted verification request", async () => { - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - - // make the room_key event, but don't send it yet - const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession); - - // Add verification request from Bob to Alice in the DM between them - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } }, - }); - // Wait for the sync response to be processed - await syncPromise(aliceClient); - - const room = aliceClient.getRoom(ROOM_ID)!; - const matrixEvent = room.getLiveTimeline().getEvents()[0]; - - // wait for a first attempt at decryption: should fail - await testUtils.awaitDecryption(matrixEvent); - expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); - - // Send the Bob's keys - syncResponder.sendOrQueueSyncResponse({ - next_batch: 2, - to_device: { - events: [toDeviceEvent], - }, - }); - await syncPromise(aliceClient); - - // Wait for the message to be decrypted - await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); - - const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); - // Expect to find the verification request received during the sync - expect(request?.roomId).toBe(TEST_ROOM_ID); - expect(request?.isSelfVerification).toBe(false); - expect(request?.otherUserId).toBe("@bob:xyz"); - }); - - newBackendOnly( - "If Bob keys are not received in the 5mins after the verification request, the request is ignored", - async () => { - const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - - // make the room_key event, but don't send it yet - const toDeviceEvent = createToDeviceEvent(groupSession, p2pSession); - - jest.useFakeTimers(); - - // Add verification request from Bob to Alice in the DM between them - syncResponder.sendOrQueueSyncResponse({ - next_batch: 1, - rooms: { join: { [ROOM_ID]: { timeline: { events: [createEncryptedMessage(groupSession)] } } } }, - }); - // Wait for the sync response to be processed - await syncPromise(aliceClient); - - const room = aliceClient.getRoom(ROOM_ID)!; - const matrixEvent = room.getLiveTimeline().getEvents()[0]; - - // wait for a first attempt at decryption: should fail - await testUtils.awaitDecryption(matrixEvent); - expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); - - // Advance time by 5mins, the verification request should be ignored after that - jest.advanceTimersByTime(5 * 60 * 1000); - - // Send the Bob's keys - syncResponder.sendOrQueueSyncResponse({ - next_batch: 2, - to_device: { - events: [toDeviceEvent], - }, - }); - await syncPromise(aliceClient); - - // Wait for the message to be decrypted - await testUtils.awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); - - const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); - // the request should not be present - expect(request).not.toBeDefined(); - }, - ); - }); - describe("Check if the cross signing keys are available for a user", () => { beforeEach(async () => { // anything that we don't have a specific matcher for silently returns a 404 diff --git a/spec/integ/crypto/olm-utils.ts b/spec/integ/crypto/olm-utils.ts new file mode 100644 index 000000000..34e2e6df7 --- /dev/null +++ b/spec/integ/crypto/olm-utils.ts @@ -0,0 +1,255 @@ +/* +Copyright 2016-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 Olm from "@matrix-org/olm"; +import anotherjson from "another-json"; + +import { IContent, IDeviceKeys, IEvent, MatrixClient } from "../../../src"; +import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { ISyncResponder } from "../../test-utils/SyncResponder"; +import { syncPromise } from "../../test-utils/test-utils"; + +/** + * @module + * + * A set of utilities for creating Olm accounts and sessions, and encrypting/decrypting with Olm/Megolm. + */ + +/** Create an Olm Account object */ +export async function createOlmAccount(): Promise { + await Olm.init(); + const testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + return testOlmAccount; +} + +/** + * Get the device keys for the test Olm Account + * + * @param olmAccount - Test olm account + * @param userId - The user ID to present the keys as belonging to + */ +export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, deviceId: string): IDeviceKeys { + const testE2eKeys = JSON.parse(olmAccount.identity_keys()); + const testDeviceKeys: IDeviceKeys = { + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: deviceId, + keys: { + [`curve25519:${deviceId}`]: testE2eKeys.curve25519, + [`ed25519:${deviceId}`]: testE2eKeys.ed25519, + }, + user_id: userId, + }; + + const j = anotherjson.stringify(testDeviceKeys); + const sig = olmAccount.sign(j); + testDeviceKeys.signatures = { [userId]: { [`ed25519:${deviceId}`]: sig } }; + return testDeviceKeys; +} + +/** start an Olm session with a given recipient */ +export async function createOlmSession( + olmAccount: Olm.Account, + recipientTestClient: IE2EKeyReceiver, +): Promise { + const keys = await recipientTestClient.awaitOneTimeKeyUpload(); + 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; +} + +// IToDeviceEvent isn't exported by src/sync-accumulator.ts +export interface ToDeviceEvent { + content: IContent; + sender: string; + type: string; +} + +/** encrypt an event with an existing olm session */ +export function encryptOlmEvent(opts: { + /** the sender's user id */ + sender?: string; + /** the sender's curve25519 key */ + senderKey: string; + /** the sender's ed25519 key */ + senderSigningKey: string; + /** the olm session to use for encryption */ + p2pSession: Olm.Session; + /** the recipient's user id */ + recipient: string; + /** the recipient's curve25519 key */ + recipientCurve25519Key: string; + /** the recipient's ed25519 key */ + recipientEd25519Key: string; + /** the payload of the message */ + plaincontent?: object; + /** the event type of the payload */ + plaintype?: string; +}): ToDeviceEvent { + expect(opts.senderKey).toBeTruthy(); + expect(opts.p2pSession).toBeTruthy(); + expect(opts.recipient).toBeTruthy(); + + const plaintext = { + content: opts.plaincontent || {}, + recipient: opts.recipient, + recipient_keys: { + ed25519: opts.recipientEd25519Key, + }, + keys: { + ed25519: opts.senderSigningKey, + }, + sender: opts.sender || "@bob:xyz", + type: opts.plaintype || "m.test", + }; + + return { + content: { + algorithm: "m.olm.v1.curve25519-aes-sha2", + ciphertext: { + [opts.recipientCurve25519Key]: opts.p2pSession.encrypt(JSON.stringify(plaintext)), + }, + sender_key: opts.senderKey, + }, + sender: opts.sender || "@bob:xyz", + type: "m.room.encrypted", + }; +} + +// encrypt an event with megolm +export function encryptMegolmEvent(opts: { + senderKey: string; + groupSession: Olm.OutboundGroupSession; + plaintext?: Partial; + room_id?: string; +}): IEvent { + expect(opts.senderKey).toBeTruthy(); + expect(opts.groupSession).toBeTruthy(); + + const plaintext = opts.plaintext || {}; + if (!plaintext.content) { + plaintext.content = { + body: "42", + msgtype: "m.text", + }; + } + if (!plaintext.type) { + plaintext.type = "m.room.message"; + } + if (!plaintext.room_id) { + expect(opts.room_id).toBeTruthy(); + plaintext.room_id = opts.room_id; + } + return encryptMegolmEventRawPlainText({ + senderKey: opts.senderKey, + groupSession: opts.groupSession, + plaintext, + }); +} + +export function encryptMegolmEventRawPlainText(opts: { + senderKey: string; + groupSession: Olm.OutboundGroupSession; + plaintext: Partial; + origin_server_ts?: number; +}): IEvent { + return { + event_id: "$test_megolm_event_" + Math.random(), + sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com", + origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)), + device_id: "testDevice", + sender_key: opts.senderKey, + session_id: opts.groupSession.session_id(), + }, + type: "m.room.encrypted", + unsigned: {}, + }; +} + +/** build an encrypted room_key event to share a group session, using an existing olm session */ +export function encryptGroupSessionKey(opts: { + /** recipient's user id */ + recipient: string; + /** the recipient's curve25519 key */ + recipientCurve25519Key: string; + /** the recipient's ed25519 key */ + recipientEd25519Key: string; + /** sender's olm account */ + olmAccount: Olm.Account; + /** sender's olm session with the recipient */ + p2pSession: Olm.Session; + groupSession: Olm.OutboundGroupSession; + room_id?: string; +}): ToDeviceEvent { + const senderKeys = JSON.parse(opts.olmAccount.identity_keys()); + return encryptOlmEvent({ + senderKey: senderKeys.curve25519, + senderSigningKey: senderKeys.ed25519, + recipient: opts.recipient, + recipientCurve25519Key: opts.recipientCurve25519Key, + recipientEd25519Key: opts.recipientEd25519Key, + p2pSession: opts.p2pSession, + plaincontent: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: opts.room_id, + session_id: opts.groupSession.session_id(), + session_key: opts.groupSession.session_key(), + }, + plaintype: "m.room_key", + }); +} + +/** + * Establish an Olm Session with the test user + * + * Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will + * establish an Olm session. + * + * @param testClient - the MatrixClient under test, which we expect to upload account keys, and to make a + * /sync request which we will respond to. + * @param keyReceiver - an IE2EKeyReceiver which will intercept the /keys/upload request from the client under test + * @param syncResponder - an ISyncResponder which will intercept /sync requests from the client under test + * @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session. + */ +export async function establishOlmSession( + testClient: MatrixClient, + keyReceiver: IE2EKeyReceiver, + syncResponder: ISyncResponder, + peerOlmAccount: Olm.Account, +): Promise { + const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys()); + const p2pSession = await createOlmSession(peerOlmAccount, keyReceiver); + const olmEvent = encryptOlmEvent({ + senderKey: peerE2EKeys.curve25519, + senderSigningKey: peerE2EKeys.ed25519, + recipient: testClient.getUserId()!, + recipientCurve25519Key: keyReceiver.getDeviceKey(), + recipientEd25519Key: keyReceiver.getSigningKey(), + p2pSession: p2pSession, + }); + syncResponder.sendOrQueueSyncResponse({ + next_batch: 1, + to_device: { events: [olmEvent] }, + }); + await syncPromise(testClient); + return p2pSession; +} diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 5bc890e61..f477db48b 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -21,12 +21,14 @@ import { MockResponse } from "fetch-mock"; import fetchMock from "fetch-mock-jest"; import { IDBFactory } from "fake-indexeddb"; import { createHash } from "crypto"; +import Olm from "@matrix-org/olm"; import { createClient, CryptoEvent, IContent, ICreateClientOpts, + IEvent, MatrixClient, MatrixEvent, MatrixEventEvent, @@ -42,9 +44,17 @@ import { VerifierEvent, } from "../../../src/crypto-api/verification"; import { escapeRegExp } from "../../../src/utils"; -import { CRYPTO_BACKENDS, emitPromise, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils"; +import { + awaitDecryption, + CRYPTO_BACKENDS, + emitPromise, + getSyncResponse, + InitCrypto, + syncPromise, +} from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { + BOB_ONE_TIME_KEYS, BOB_SIGNED_CROSS_SIGNING_KEYS_DATA, BOB_SIGNED_TEST_DEVICE_DATA, BOB_TEST_USER_ID, @@ -55,11 +65,11 @@ import { TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, TEST_ROOM_ID, TEST_USER_ID, - BOB_ONE_TIME_KEYS, } from "../../test-utils/test-data"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { createOlmSession, encryptGroupSessionKey, encryptMegolmEvent, ToDeviceEvent } from "./olm-utils"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations // to ensure that we don't end up with dangling timeouts. @@ -897,6 +907,175 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }); }); + describe("Incoming verification in a DM", () => { + let testOlmAccount: Olm.Account; + + beforeEach(async () => { + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + await Olm.init(); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + + aliceClient = await startTestClient(); + aliceClient.setGlobalErrorOnUnknownDevices(false); + syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID])); + await syncPromise(aliceClient); + }); + + /** + * Return a plaintext verification request event from Bob to Alice + * @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest + */ + function createVerificationRequestEvent(): IEvent { + return { + content: { + body: "Verification request from Bob to Alice", + from_device: "BobDevice", + methods: ["m.sas.v1"], + msgtype: "m.key.verification.request", + to: aliceClient.getUserId()!, + }, + event_id: "$143273582443PhrSn:example.org", + origin_server_ts: Date.now(), + room_id: TEST_ROOM_ID, + sender: "@bob:xyz", + type: "m.room.message", + unsigned: { + age: 1234, + }, + }; + } + + /** + * Create a to-device event from Bob to Alice, sharing the group session key + * @param groupSession - group session key to share + * @param p2pSession - test Olm session to encrypt the key with + */ + function encryptGroupSessionKeyForAlice( + groupSession: Olm.OutboundGroupSession, + p2pSession: Olm.Session, + ): ToDeviceEvent { + return encryptGroupSessionKey({ + recipient: aliceClient.getUserId()!, + recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(), + recipientEd25519Key: e2eKeyReceiver.getSigningKey(), + olmAccount: testOlmAccount, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: TEST_ROOM_ID, + }); + } + + /** + * Create and encrypt a verification request event + * @param groupSession + */ + function createEncryptedVerificationRequest(groupSession: Olm.OutboundGroupSession): IEvent { + const testOlmAccountKeys = JSON.parse(testOlmAccount.identity_keys()); + return encryptMegolmEvent({ + senderKey: testOlmAccountKeys.curve25519, + groupSession: groupSession, + room_id: TEST_ROOM_ID, + plaintext: createVerificationRequestEvent(), + }); + } + + it("Plaintext verification request from Bob to Alice", async () => { + // Add verification request from Bob to Alice in the DM between them + returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent()); + + // Wait for the sync response to be processed + await syncPromise(aliceClient); + + const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); + // Expect to find the verification request received during the sync + expect(request?.roomId).toBe(TEST_ROOM_ID); + expect(request?.isSelfVerification).toBe(false); + expect(request?.otherUserId).toBe("@bob:xyz"); + }); + + it("Verification request not found", async () => { + // Expect to not find any verification request + const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); + expect(request).not.toBeDefined(); + }); + + it("Encrypted verification request from Bob to Alice", async () => { + const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver); + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event, but don't send it yet + const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession); + + // Add verification request from Bob to Alice in the DM between them + returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession)); + + // Wait for the sync response to be processed + await syncPromise(aliceClient); + + const room = aliceClient.getRoom(TEST_ROOM_ID)!; + const matrixEvent = room.getLiveTimeline().getEvents()[0]; + + // wait for a first attempt at decryption: should fail + await awaitDecryption(matrixEvent); + expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); + + // Send Bob the room keys + returnToDeviceMessageFromSync(toDeviceEvent); + + // advance the clock, because the devicelist likes to sleep for 5ms during key downloads + await jest.advanceTimersByTimeAsync(10); + + // Wait for the message to be decrypted + await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); + + const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); + // Expect to find the verification request received during the sync + expect(request?.roomId).toBe(TEST_ROOM_ID); + expect(request?.isSelfVerification).toBe(false); + expect(request?.otherUserId).toBe("@bob:xyz"); + }); + + newBackendOnly( + "If the verification request is not decrypted within 5 minutes, the request is ignored", + async () => { + const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver); + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event, but don't send it yet + const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession); + + // Add verification request from Bob to Alice in the DM between them + returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession)); + + // Wait for the sync response to be processed + await syncPromise(aliceClient); + + const room = aliceClient.getRoom(TEST_ROOM_ID)!; + const matrixEvent = room.getLiveTimeline().getEvents()[0]; + + // wait for a first attempt at decryption: should fail + await awaitDecryption(matrixEvent); + expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted"); + + // Advance time by 5mins, the verification request should be ignored after that + jest.advanceTimersByTime(5 * 60 * 1000); + + // Send Bob the room keys + returnToDeviceMessageFromSync(toDeviceEvent); + + // Wait for the message to be decrypted + await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true }); + + const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"); + // the request should not be present + expect(request).not.toBeDefined(); + }, + ); + }); + async function startTestClient(opts: Partial = {}): Promise { const client = createClient({ baseUrl: TEST_HOMESERVER_URL, @@ -927,6 +1106,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); } + + function returnRoomMessageFromSync(roomId: string, ev: IEvent): void { + syncResponder.sendOrQueueSyncResponse({ + next_batch: 1, + rooms: { + join: { + [roomId]: { timeline: { events: [ev] } }, + }, + }, + }); + } }); /**