You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-06 12:02:40 +03:00
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
This commit is contained in:
committed by
GitHub
parent
3c59476cf7
commit
f884c78579
@@ -40,6 +40,7 @@ import {
|
|||||||
TEST_USER_ID,
|
TEST_USER_ID,
|
||||||
} from "../../test-utils/test-data";
|
} from "../../test-utils/test-data";
|
||||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
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
|
// 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.
|
// 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
|
* 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.
|
* 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) => {
|
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
|
// 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.
|
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||||
@@ -90,6 +102,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
|||||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||||
let syncResponder: SyncResponder;
|
let syncResponder: SyncResponder;
|
||||||
|
|
||||||
|
/** an object which intercepts `/keys/query` requests from {@link #aliceClient} */
|
||||||
|
let e2eKeyResponder: E2EKeyResponder;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// anything that we don't have a specific matcher for silently returns a 404
|
// anything that we don't have a specific matcher for silently returns a 404
|
||||||
fetchMock.catch(404);
|
fetchMock.catch(404);
|
||||||
@@ -101,9 +116,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
|||||||
userId: TEST_USER_ID,
|
userId: TEST_USER_ID,
|
||||||
accessToken: "akjgkrgjs",
|
accessToken: "akjgkrgjs",
|
||||||
deviceId: "device_under_test",
|
deviceId: "device_under_test",
|
||||||
|
verificationMethods: methods,
|
||||||
});
|
});
|
||||||
|
|
||||||
await initCrypto(aliceClient);
|
await initCrypto(aliceClient);
|
||||||
|
|
||||||
|
e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
|
||||||
|
syncResponder = new SyncResponder(aliceClient.getHomeserverUrl());
|
||||||
|
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||||
|
await aliceClient.startClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -111,156 +132,157 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
|||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
describe("Outgoing verification requests for another device", () => {
|
||||||
syncResponder = new SyncResponder(aliceClient.getHomeserverUrl());
|
beforeEach(async () => {
|
||||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
// pretend that we have another device, which we will verify
|
||||||
aliceClient.startClient();
|
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
|
||||||
});
|
});
|
||||||
|
|
||||||
oldBackendOnly("Outgoing verification: can verify another device via SAS", async () => {
|
oldBackendOnly("can verify via SAS", async () => {
|
||||||
// expect requests to download our own keys
|
// have alice initiate a verification. She should send a m.key.verification.request
|
||||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
let [requestBody, request] = await Promise.all([
|
||||||
device_keys: {
|
expectSendToDeviceMessage("m.key.verification.request"),
|
||||||
[TEST_USER_ID]: {
|
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
]);
|
||||||
|
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
|
// ... and picks a method with m.key.verification.start
|
||||||
let [requestBody, request] = await Promise.all([
|
returnToDeviceMessageFromSync({
|
||||||
expectSendToDeviceMessage("m.key.verification.request"),
|
type: "m.key.verification.start",
|
||||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
content: {
|
||||||
]);
|
from_device: TEST_DEVICE_ID,
|
||||||
const transactionId = request.transactionId;
|
method: "m.sas.v1",
|
||||||
expect(transactionId).toBeDefined();
|
transaction_id: transactionId,
|
||||||
expect(request.phase).toEqual(VerificationPhase.Requested);
|
hashes: ["sha256"],
|
||||||
expect(request.roomId).toBeUndefined();
|
key_agreement_protocols: ["curve25519-hkdf-sha256"],
|
||||||
|
message_authentication_codes: ["hkdf-hmac-sha256.v2"],
|
||||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
// we have to include "decimal" per the spec.
|
||||||
expect(toDeviceMessage.methods).toContain("m.sas.v1");
|
short_authentication_string: ["decimal", "emoji"],
|
||||||
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(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 client is notified to show the emoji
|
|
||||||
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
|
||||||
verifier.once(VerifierEvent.ShowSas, resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
// `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}`,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
await waitForVerificationRequestChanged(request);
|
||||||
|
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||||
|
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true);
|
||||||
|
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||||
|
|
||||||
// that should satisfy Alice, who should reply with a 'done'
|
// there should now be a verifier
|
||||||
await expectSendToDeviceMessage("m.key.verification.done");
|
const verifier: Verifier = request.verifier!;
|
||||||
|
expect(verifier).toBeDefined();
|
||||||
|
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||||
|
|
||||||
// ... and the whole thing should be done!
|
// start off the verification process: alice will send an `accept`
|
||||||
await verificationPromise;
|
const verificationPromise = verifier.verify();
|
||||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||||
|
jest.advanceTimersByTime(10);
|
||||||
|
|
||||||
// we're done with the temporary keypair
|
requestBody = await expectSendToDeviceMessage("m.key.verification.accept");
|
||||||
olmSAS.free();
|
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);
|
||||||
|
|
||||||
oldBackendOnly(
|
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||||
"Outgoing verification: can verify another device via QR code with an untrusted cross-signing key",
|
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||||
async () => {
|
const olmSAS = new global.Olm.SAS();
|
||||||
// expect requests to download our own keys
|
returnToDeviceMessageFromSync({
|
||||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
type: "m.key.verification.key",
|
||||||
device_keys: {
|
content: {
|
||||||
[TEST_USER_ID]: {
|
transaction_id: transactionId,
|
||||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
key: olmSAS.get_pubkey(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 client is notified to show the emoji
|
||||||
|
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
||||||
|
verifier.once(VerifierEvent.ShowSas, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `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}`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...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.
|
// 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
|
// 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];
|
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.show.v1");
|
||||||
expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1");
|
|
||||||
expect(toDeviceMessage.methods).toContain("m.reciprocate.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.from_device).toEqual(aliceClient.deviceId);
|
||||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
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!
|
// ... and the whole thing should be done!
|
||||||
await verificationPromise;
|
await verificationPromise;
|
||||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
oldBackendOnly("Incoming verification: can accept", async () => {
|
oldBackendOnly("can cancel during the SAS phase", async () => {
|
||||||
// expect requests to download our own keys
|
// have alice initiate a verification. She should send a m.key.verification.request
|
||||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
const [, request] = await Promise.all([
|
||||||
device_keys: {
|
expectSendToDeviceMessage("m.key.verification.request"),
|
||||||
[TEST_USER_ID]: {
|
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
]);
|
||||||
|
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", () => {
|
||||||
|
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
|
// Initiate the request by sending a to-device message
|
||||||
returnToDeviceMessageFromSync({
|
returnToDeviceMessageFromSync({
|
||||||
type: "m.key.verification.request",
|
type: "m.key.verification.request",
|
||||||
content: {
|
content: {
|
||||||
from_device: TEST_DEVICE_ID,
|
from_device: TEST_DEVICE_ID,
|
||||||
methods: ["m.sas.v1"],
|
methods: ["m.sas.v1"],
|
||||||
transaction_id: TRANSACTION_ID,
|
transaction_id: TRANSACTION_ID,
|
||||||
timestamp: Date.now() - 1000,
|
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 {
|
function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void {
|
||||||
ev.sender ??= TEST_USER_ID;
|
ev.sender ??= TEST_USER_ID;
|
||||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
|
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the client under test to send a to-device message of the given type.
|
* Wait for the client under test to send a to-device message of the given type.
|
||||||
|
99
spec/test-utils/E2EKeyResponder.ts
Normal file
99
spec/test-utils/E2EKeyResponder.ts
Normal file
@@ -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<string, Map<string, any>>(() => new Map());
|
||||||
|
private masterKeysByUser: Record<string, any> = {};
|
||||||
|
private selfSigningKeysByUser: Record<string, any> = {};
|
||||||
|
private userSigningKeysByUser: Record<string, any> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<IDownloadKeyResult, "master_keys" | "self_signing_keys" | "user_signing_keys">,
|
||||||
|
) {
|
||||||
|
Object.assign(this.masterKeysByUser, data.master_keys);
|
||||||
|
Object.assign(this.selfSigningKeysByUser, data.self_signing_keys);
|
||||||
|
Object.assign(this.userSigningKeysByUser, data.user_signing_keys);
|
||||||
|
}
|
||||||
|
}
|
@@ -336,6 +336,11 @@ export interface ICreateClientOpts {
|
|||||||
*/
|
*/
|
||||||
pickleKey?: string;
|
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<VerificationMethod>;
|
verificationMethods?: Array<VerificationMethod>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -256,7 +256,12 @@ export interface CryptoApi {
|
|||||||
/**
|
/**
|
||||||
* Send a verification request to our other devices.
|
* 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.
|
* @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.
|
* 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 userId - ID of the owner of the device to verify
|
||||||
* @param deviceId - ID of the device to verify
|
* @param deviceId - ID of the device to verify
|
||||||
|
Reference in New Issue
Block a user