From f884c78579c336a03bc20ff8f4e92c46582822b6 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 23 Jun 2023 15:38:38 +0100 Subject: [PATCH] Improve integration test for interactive verification (#3495) * Tweaks to the integ test to conform to the spec Rust is a bit more insistent than legacy crypto... * Improve documentation on request*Verification * Check more things in the integration test * Create an E2EKeyResponder * Test verification with custom method list * Add a test for SAS cancellation * Update spec/integ/crypto/verification.spec.ts --- spec/integ/crypto/verification.spec.ts | 432 +++++++++++++++---------- spec/test-utils/E2EKeyResponder.ts | 99 ++++++ src/client.ts | 5 + src/crypto-api.ts | 15 +- 4 files changed, 374 insertions(+), 177 deletions(-) create mode 100644 spec/test-utils/E2EKeyResponder.ts diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index b5f500f5887..960dbb375d0 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -40,6 +40,7 @@ import { TEST_USER_ID, } from "../../test-utils/test-data"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; // 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. @@ -79,7 +80,18 @@ afterAll(() => { * These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as * to provide the most effective integration tests possible. */ +// we test with both crypto stacks... describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => { + // and with (1) the default verification method list, (2) a custom verification method list. + describe.each([undefined, ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]])( + "supported methods=%s", + (methods) => { + runTests(backend, initCrypto, methods); + }, + ); +}); + +function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | undefined) { // 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; @@ -90,6 +102,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st /** an object which intercepts `/sync` requests from {@link #aliceClient} */ let syncResponder: SyncResponder; + /** an object which intercepts `/keys/query` requests from {@link #aliceClient} */ + let e2eKeyResponder: E2EKeyResponder; + beforeEach(async () => { // anything that we don't have a specific matcher for silently returns a 404 fetchMock.catch(404); @@ -101,9 +116,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st userId: TEST_USER_ID, accessToken: "akjgkrgjs", deviceId: "device_under_test", + verificationMethods: methods, }); await initCrypto(aliceClient); + + e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); + mockInitialApiRequests(aliceClient.getHomeserverUrl()); + await aliceClient.startClient(); }); afterEach(async () => { @@ -111,156 +132,157 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st fetchMock.mockReset(); }); - beforeEach(() => { - syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); - mockInitialApiRequests(aliceClient.getHomeserverUrl()); - aliceClient.startClient(); - }); + describe("Outgoing verification requests for another device", () => { + beforeEach(async () => { + // pretend that we have another device, which we will verify + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + }); - oldBackendOnly("Outgoing verification: can verify another device via SAS", async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + oldBackendOnly("can verify via SAS", async () => { + // have alice initiate a verification. She should send a m.key.verification.request + let [requestBody, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), + ]); + const transactionId = request.transactionId; + expect(transactionId).toBeDefined(); + expect(request.phase).toEqual(VerificationPhase.Requested); + expect(request.roomId).toBeUndefined(); + expect(request.isSelfVerification).toBe(true); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(false); // no reply yet + expect(request.chosenMethod).toBe(null); // nothing chosen yet + expect(request.initiatedByMe).toBe(true); + expect(request.otherUserId).toEqual(TEST_USER_ID); + + let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + if (methods !== undefined) { + // eslint-disable-next-line jest/no-conditional-expect + expect(new Set(toDeviceMessage.methods)).toEqual(new Set(methods)); + } + + // The dummy device replies with an m.key.verification.ready... + returnToDeviceMessageFromSync({ + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: transactionId, }, - }, - }); + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Ready); + expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); - // have alice initiate a verification. She should send a m.key.verification.request - let [requestBody, request] = await Promise.all([ - expectSendToDeviceMessage("m.key.verification.request"), - aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), - ]); - const transactionId = request.transactionId; - expect(transactionId).toBeDefined(); - expect(request.phase).toEqual(VerificationPhase.Requested); - expect(request.roomId).toBeUndefined(); - - let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.methods).toContain("m.sas.v1"); - expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // The dummy device replies with an m.key.verification.ready... - returnToDeviceMessageFromSync({ - type: "m.key.verification.ready", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: transactionId, - }, - }); - await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(VerificationPhase.Ready); - expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); - - // ... and picks a method with m.key.verification.start - returnToDeviceMessageFromSync({ - type: "m.key.verification.start", - content: { - from_device: TEST_DEVICE_ID, - method: "m.sas.v1", - transaction_id: transactionId, - hashes: ["sha256"], - key_agreement_protocols: ["curve25519"], - message_authentication_codes: ["hkdf-hmac-sha256.v2"], - short_authentication_string: ["emoji"], - }, - }); - await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(VerificationPhase.Started); - expect(request.chosenMethod).toEqual("m.sas.v1"); - - // there should now be a verifier - const verifier: Verifier = request.verifier!; - expect(verifier).toBeDefined(); - expect(verifier.getShowSasCallbacks()).toBeNull(); - - // start off the verification process: alice will send an `accept` - const verificationPromise = verifier.verify(); - // advance the clock, because the devicelist likes to sleep for 5ms during key downloads - jest.advanceTimersByTime(10); - - requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519"); - expect(toDeviceMessage.short_authentication_string).toEqual(["emoji"]); - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key' - // We use the Curve25519, HMAC and HKDF implementations in libolm, for now - const olmSAS = new global.Olm.SAS(); - returnToDeviceMessageFromSync({ - type: "m.key.verification.key", - content: { - transaction_id: transactionId, - key: olmSAS.get_pubkey(), - }, - }); + // ... and picks a method with m.key.verification.start + returnToDeviceMessageFromSync({ + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.sas.v1", + transaction_id: transactionId, + hashes: ["sha256"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], + message_authentication_codes: ["hkdf-hmac-sha256.v2"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Started); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true); + expect(request.chosenMethod).toEqual("m.sas.v1"); - // alice responds with a 'key' ... - requestBody = await expectSendToDeviceMessage("m.key.verification.key"); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - const aliceDevicePubKeyBase64 = toDeviceMessage.key; - olmSAS.set_their_key(aliceDevicePubKeyBase64); + // there should now be a verifier + const verifier: Verifier = request.verifier!; + expect(verifier).toBeDefined(); + expect(verifier.getShowSasCallbacks()).toBeNull(); - // ... and the client is notified to show the emoji - const showSas = await new Promise((resolve) => { - verifier.once(VerifierEvent.ShowSas, resolve); - }); + // start off the verification process: alice will send an `accept` + const verificationPromise = verifier.verify(); + // advance the clock, because the devicelist likes to sleep for 5ms during key downloads + jest.advanceTimersByTime(10); + + requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256"); + expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]); + const macMethod = toDeviceMessage.message_authentication_code; + expect(macMethod).toEqual("hkdf-hmac-sha256.v2"); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); - // `getShowSasCallbacks` is an alternative way to get the callbacks - expect(verifier.getShowSasCallbacks()).toBe(showSas); - expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); - - // user confirms that the emoji match, and alice sends a 'mac' - [requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // the dummy device also confirms that the emoji match, and sends a mac - const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`; - returnToDeviceMessageFromSync({ - type: "m.key.verification.mac", - content: { - keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`), - transaction_id: transactionId, - mac: { - [`ed25519:${TEST_DEVICE_ID}`]: calculateMAC( - olmSAS, - TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, - `${macInfoBase}ed25519:${TEST_DEVICE_ID}`, - ), + // The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key' + // We use the Curve25519, HMAC and HKDF implementations in libolm, for now + const olmSAS = new global.Olm.SAS(); + returnToDeviceMessageFromSync({ + type: "m.key.verification.key", + content: { + transaction_id: transactionId, + key: olmSAS.get_pubkey(), }, - }, - }); + }); - // that should satisfy Alice, who should reply with a 'done' - await expectSendToDeviceMessage("m.key.verification.done"); + // alice responds with a 'key' ... + requestBody = await expectSendToDeviceMessage("m.key.verification.key"); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + const aliceDevicePubKeyBase64 = toDeviceMessage.key; + olmSAS.set_their_key(aliceDevicePubKeyBase64); - // ... and the whole thing should be done! - await verificationPromise; - expect(request.phase).toEqual(VerificationPhase.Done); + // ... and the client is notified to show the emoji + const showSas = await new Promise((resolve) => { + verifier.once(VerifierEvent.ShowSas, resolve); + }); - // we're done with the temporary keypair - olmSAS.free(); - }); + // `getShowSasCallbacks` is an alternative way to get the callbacks + expect(verifier.getShowSasCallbacks()).toBe(showSas); + expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); + + // user confirms that the emoji match, and alice sends a 'mac' + [requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.transaction_id).toEqual(transactionId); - oldBackendOnly( - "Outgoing verification: can verify another device via QR code with an untrusted cross-signing key", - async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + // the dummy device also confirms that the emoji match, and sends a mac + const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`; + returnToDeviceMessageFromSync({ + type: "m.key.verification.mac", + content: { + keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`), + transaction_id: transactionId, + mac: { + [`ed25519:${TEST_DEVICE_ID}`]: calculateMAC( + olmSAS, + TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, + `${macInfoBase}ed25519:${TEST_DEVICE_ID}`, + ), }, }, - ...SIGNED_CROSS_SIGNING_KEYS_DATA, }); + // that should satisfy Alice, who should reply with a 'done' + await expectSendToDeviceMessage("m.key.verification.done"); + + // the dummy device also confirms done-ness + returnToDeviceMessageFromSync({ + type: "m.key.verification.done", + content: { + transaction_id: transactionId, + }, + }); + + // ... and the whole thing should be done! + await verificationPromise; + expect(request.phase).toEqual(VerificationPhase.Done); + + // we're done with the temporary keypair + olmSAS.free(); + }); + + oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { + e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); + // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. // // Completing the initial sync will make the device list download outdated device lists (of which our own @@ -279,8 +301,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1"); - expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); expect(toDeviceMessage.methods).toContain("m.reciprocate.v1"); + if (methods === undefined) { + expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); + } expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); expect(toDeviceMessage.transaction_id).toEqual(transactionId); @@ -351,57 +375,115 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // ... and the whole thing should be done! await verificationPromise; expect(request.phase).toEqual(VerificationPhase.Done); - }, - ); + }); + + oldBackendOnly("can cancel during the SAS phase", async () => { + // have alice initiate a verification. She should send a m.key.verification.request + const [, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), + ]); + const transactionId = request.transactionId; - oldBackendOnly("Incoming verification: can accept", async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + // The dummy device replies with an m.key.verification.ready... + returnToDeviceMessageFromSync({ + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: transactionId, }, - }, + }); + await waitForVerificationRequestChanged(request); + + // ... and picks a method with m.key.verification.start + returnToDeviceMessageFromSync({ + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.sas.v1", + transaction_id: transactionId, + hashes: ["sha256"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], + message_authentication_codes: ["hkdf-hmac-sha256.v2"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Started); + + // there should now be a verifier... + const verifier: Verifier = request.verifier!; + expect(verifier).toBeDefined(); + expect(verifier.hasBeenCancelled).toBe(false); + + // start off the verification process: alice will send an `accept` + const verificationPromise = verifier.verify(); + // advance the clock, because the devicelist likes to sleep for 5ms during key downloads + jest.advanceTimersByTime(10); + await expectSendToDeviceMessage("m.key.verification.accept"); + + // now we unceremoniously cancel + const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel"); + verifier.cancel(new Error("blah")); + await requestPromise; + + // ... which should cancel the verifier + await expect(verificationPromise).rejects.toThrow(); + expect(request.phase).toEqual(VerificationPhase.Cancelled); + expect(verifier.hasBeenCancelled).toBe(true); }); + }); - const TRANSACTION_ID = "abcd"; + describe("Incoming verification from another device", () => { + beforeEach(() => { + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + }); - // Initiate the request by sending a to-device message - returnToDeviceMessageFromSync({ - type: "m.key.verification.request", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: TRANSACTION_ID, - timestamp: Date.now() - 1000, - }, + oldBackendOnly("Incoming verification: can accept", async () => { + const TRANSACTION_ID = "abcd"; + + // Initiate the request by sending a to-device message + returnToDeviceMessageFromSync({ + type: "m.key.verification.request", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: TRANSACTION_ID, + timestamp: Date.now() - 1000, + }, + }); + const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); + expect(request.transactionId).toEqual(TRANSACTION_ID); + expect(request.phase).toEqual(VerificationPhase.Requested); + expect(request.roomId).toBeUndefined(); + expect(request.initiatedByMe).toBe(false); + expect(request.otherUserId).toEqual(TEST_USER_ID); + expect(request.chosenMethod).toBe(null); // nothing chosen yet + expect(canAcceptVerificationRequest(request)).toBe(true); + + // Alice accepts, by sending a to-device message + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); + const acceptPromise = request.accept(); + expect(canAcceptVerificationRequest(request)).toBe(false); + expect(request.phase).toEqual(VerificationPhase.Requested); + await acceptPromise; + const requestBody = await sendToDevicePromise; + expect(request.phase).toEqual(VerificationPhase.Ready); + + const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.methods).toContain("m.sas.v1"); + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID); }); - const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); - expect(request.transactionId).toEqual(TRANSACTION_ID); - expect(request.phase).toEqual(VerificationPhase.Requested); - expect(request.roomId).toBeUndefined(); - expect(canAcceptVerificationRequest(request)).toBe(true); - - // Alice accepts, by sending a to-device message - const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); - const acceptPromise = request.accept(); - expect(canAcceptVerificationRequest(request)).toBe(false); - expect(request.phase).toEqual(VerificationPhase.Requested); - await acceptPromise; - const requestBody = await sendToDevicePromise; - expect(request.phase).toEqual(VerificationPhase.Ready); - - const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.methods).toContain("m.sas.v1"); - expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); - expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID); }); function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); } -}); +} /** * Wait for the client under test to send a to-device message of the given type. diff --git a/spec/test-utils/E2EKeyResponder.ts b/spec/test-utils/E2EKeyResponder.ts new file mode 100644 index 00000000000..a704d756d45 --- /dev/null +++ b/spec/test-utils/E2EKeyResponder.ts @@ -0,0 +1,99 @@ +/* +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 fetchMock from "fetch-mock-jest"; + +import { MapWithDefault } from "../../src/utils"; +import { IDownloadKeyResult } from "../../src"; +import { IDeviceKeys } from "../../src/@types/crypto"; + +/** + * An object which intercepts `/keys/query` fetches via fetch-mock. + */ +export class E2EKeyResponder { + private deviceKeysByUserByDevice = new MapWithDefault>(() => new Map()); + private masterKeysByUser: Record = {}; + private selfSigningKeysByUser: Record = {}; + private userSigningKeysByUser: Record = {}; + + /** + * Construct a new E2EKeyResponder. + * + * It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl. + * Only /query requests made to this server will be intercepted: this allows a single test to use more than one + * client and have the keys collected separately. + * + * @param homeserverUrl - the Homeserver Url of the client under test. + */ + public constructor(homeserverUrl: string) { + // set up a listener for /keys/query. + const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options); + // catch both r0 and v3 variants + fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener); + fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener); + } + + private onKeyQueryRequest(options: RequestInit) { + const content = JSON.parse(options.body as string); + const usersToReturn = Object.keys(content["device_keys"]); + const response = { + device_keys: {} as { [userId: string]: any }, + master_keys: {} as { [userId: string]: any }, + self_signing_keys: {} as { [userId: string]: any }, + user_signing_keys: {} as { [userId: string]: any }, + failures: {} as { [serverName: string]: any }, + }; + for (const user of usersToReturn) { + const userKeys = this.deviceKeysByUserByDevice.get(user); + if (userKeys !== undefined) { + response.device_keys[user] = Object.fromEntries(userKeys.entries()); + } + if (this.masterKeysByUser.hasOwnProperty(user)) { + response.master_keys[user] = this.masterKeysByUser[user]; + } + if (this.selfSigningKeysByUser.hasOwnProperty(user)) { + response.self_signing_keys[user] = this.selfSigningKeysByUser[user]; + } + if (this.userSigningKeysByUser.hasOwnProperty(user)) { + response.user_signing_keys[user] = this.userSigningKeysByUser[user]; + } + } + return response; + } + + /** + * Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed + * + * @param userId - user the keys belong to + * @param deviceId - device the keys belong to + * @param keys - device keys for this device. + */ + public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) { + this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys); + } + + /** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed + * + * @param data cross-signing data + */ + public addCrossSigningData( + data: Pick, + ) { + Object.assign(this.masterKeysByUser, data.master_keys); + Object.assign(this.selfSigningKeysByUser, data.self_signing_keys); + Object.assign(this.userSigningKeysByUser, data.user_signing_keys); + } +} diff --git a/src/client.ts b/src/client.ts index 5297eee9d88..18eec33671f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -336,6 +336,11 @@ export interface ICreateClientOpts { */ pickleKey?: string; + /** + * Verification methods we should offer to the other side when performing an interactive verification. + * If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS + * (aka "emojis"). + */ verificationMethods?: Array; /** diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 967fc8a61a6..7428a3181c3 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -256,7 +256,12 @@ export interface CryptoApi { /** * Send a verification request to our other devices. * - * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * This is normally used when the current device is new, and we want to ask another of our devices to cross-sign. + * + * If an all-devices verification is already in flight, returns it. Otherwise, initiates a new one. + * + * To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the + * MatrixClient. * * @returns a VerificationRequest when the request has been sent to the other party. */ @@ -265,7 +270,13 @@ export interface CryptoApi { /** * Request an interactive verification with the given device. * - * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * This is normally used on one of our own devices, when the current device is already cross-signed, and we want to + * validate another device. + * + * If a verification for this user/device is already in flight, returns it. Otherwise, initiates a new one. + * + * To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the + * MatrixClient. * * @param userId - ID of the owner of the device to verify * @param deviceId - ID of the device to verify