From ca08ecaf40d37eb5980e7ad1dac232762b8e59d0 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 14 Jun 2023 17:59:10 +0100 Subject: [PATCH 1/7] Tweaks to the integ test to conform to the spec Rust is a bit more insistent than legacy crypto... --- spec/integ/crypto/verification.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index b5f500f5887..283a35c2648 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -163,9 +163,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st method: "m.sas.v1", transaction_id: transactionId, hashes: ["sha256"], - key_agreement_protocols: ["curve25519"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], message_authentication_codes: ["hkdf-hmac-sha256.v2"], - short_authentication_string: ["emoji"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], }, }); await waitForVerificationRequestChanged(request); @@ -184,8 +185,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st 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.key_agreement_protocol).toEqual("curve25519-hkdf-sha256"); + expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "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' @@ -240,6 +241,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // 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); From 1dbf4d4f2e72f05620bc08e2fafe23f3e3c8dbe3 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 19 Jun 2023 19:43:58 +0100 Subject: [PATCH 2/7] Improve documentation on request*Verification --- src/client.ts | 5 +++++ src/crypto-api.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index d3e6c4524db..175e2504241 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 1a78f11b434..280c953989d 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -251,7 +251,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. */ @@ -260,7 +265,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 From 967165a2c1129956e39af0d705c0644f46413511 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 20 Jun 2023 09:53:36 +0100 Subject: [PATCH 3/7] Check more things in the integration test --- spec/integ/crypto/verification.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 283a35c2648..9955ac0fa12 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -136,6 +136,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st 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.methods).toContain("m.sas.v1"); @@ -171,6 +176,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }); await waitForVerificationRequestChanged(request); expect(request.phase).toEqual(VerificationPhase.Started); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true); expect(request.chosenMethod).toEqual("m.sas.v1"); // there should now be a verifier @@ -187,6 +193,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st 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); // The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key' @@ -389,6 +397,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st 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 From a4f399bd8544a5b9bb1dc3f9145ecbaf41287c58 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 20 Jun 2023 12:07:24 +0100 Subject: [PATCH 4/7] Create an E2EKeyResponder --- spec/integ/crypto/verification.spec.ts | 375 ++++++++++++------------- spec/test-utils/E2EKeyResponder.ts | 99 +++++++ 2 files changed, 280 insertions(+), 194 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 9955ac0fa12..10612be141c 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. @@ -90,6 +91,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); @@ -104,6 +108,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }); await initCrypto(aliceClient); + + e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); + mockInitialApiRequests(aliceClient.getHomeserverUrl()); + aliceClient.startClient(); }); afterEach(async () => { @@ -111,173 +120,154 @@ 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.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); - // 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.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-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"); - - // 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-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); - - // 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); - // the dummy device also confirms done-ness - returnToDeviceMessageFromSync({ - type: "m.key.verification.done", - content: { - transaction_id: transactionId, - }, - }); + // ... and the client is notified to show the emoji + const showSas = await new Promise((resolve) => { + verifier.once(VerifierEvent.ShowSas, resolve); + }); - // ... and the whole thing should be done! - await verificationPromise; - expect(request.phase).toEqual(VerificationPhase.Done); + // `getShowSasCallbacks` is an alternative way to get the callbacks + expect(verifier.getShowSasCallbacks()).toBe(showSas); + expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); - // we're done with the temporary keypair - olmSAS.free(); - }); + // 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 @@ -368,53 +358,50 @@ 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("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, - }, - }, + }); + }); + + describe("Incoming verification from another device", () => { + beforeEach(() => { + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); }); - const TRANSACTION_ID = "abcd"; + 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, - }, + // 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(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); }); function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { 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); + } +} From ae0369d1bd8cc5f3116abbbb5cdf743bf56e65e3 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 20 Jun 2023 13:04:50 +0100 Subject: [PATCH 5/7] Test verification with custom method list --- spec/integ/crypto/verification.spec.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 10612be141c..fb46d0f0d0d 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -80,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; @@ -105,6 +116,7 @@ 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); @@ -143,9 +155,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st expect(request.otherUserId).toEqual(TEST_USER_ID); 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); + 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({ @@ -286,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); @@ -408,7 +425,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st 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. From 09445ae40dcf4dffebd45d863aa328941a38ad6e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 20 Jun 2023 14:22:00 +0100 Subject: [PATCH 6/7] Add a test for SAS cancellation --- spec/integ/crypto/verification.spec.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index fb46d0f0d0d..9dac1f805ca 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -376,6 +376,64 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u 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; + + // 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); + }); }); describe("Incoming verification from another device", () => { From db1856f52d774567f7cc35e3c04dbf30d46bdcd8 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 23 Jun 2023 13:34:17 +0100 Subject: [PATCH 7/7] Update spec/integ/crypto/verification.spec.ts --- spec/integ/crypto/verification.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 9dac1f805ca..960dbb375d0 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -124,7 +124,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); mockInitialApiRequests(aliceClient.getHomeserverUrl()); - aliceClient.startClient(); + await aliceClient.startClient(); }); afterEach(async () => {