Skip to content

Add an integration test for verification #3436

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ out

# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md

# This file is also autogenerated
/spec/test-utils/test-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";

import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../../src";

afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
Expand Down
32 changes: 14 additions & 18 deletions spec/integ/crypto.spec.ts → spec/integ/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { MockResponse, MockResponseFunction } from "fetch-mock";

import type { IDeviceKeys } from "../../src/@types/crypto";
import * as testUtils from "../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { logger } from "../../src/logger";
import type { IDeviceKeys } from "../../../src/@types/crypto";
import * as testUtils from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import {
createClient,
IClaimOTKsResult,
Expand All @@ -43,13 +43,14 @@ import {
Room,
RoomMember,
RoomStateEvent,
} from "../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
import { escapeRegExp } from "../../src/utils";
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
import { flushPromises } from "../test-utils/flushPromises";
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { escapeRegExp } from "../../../src/utils";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
import { flushPromises } from "../../test-utils/flushPromises";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";

const ROOM_ID = "!room:id";

Expand Down Expand Up @@ -419,12 +420,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
logger.log(aliceClient.getUserId() + ": starting");

const homeserverUrl = aliceClient.getHomeserverUrl();
fetchMock.get(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
fetchMock.get(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
fetchMock.post(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
mockInitialApiRequests(aliceClient.getHomeserverUrl());

// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ limitations under the License.

import { Account } from "@matrix-org/olm";

import { logger } from "../../src/logger";
import { decodeRecoveryKey } from "../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup";
import { TestClient } from "../TestClient";
import { IEvent } from "../../src";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { logger } from "../../../src/logger";
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
import { TestClient } from "../../TestClient";
import { IEvent } from "../../../src";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";

const ROOM_ID = "!ROOM:ID";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ limitations under the License.
*/

// load olm before the sdk if possible
import "../olm-loader";
import "../../olm-loader";

import type { Session } from "@matrix-org/olm";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
import { logger } from "../../src/logger";
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
import { logger } from "../../../src/logger";
import * as testUtils from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";

let aliTestClient: TestClient;
const roomId = "!room:localhost";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";

import { createClient } from "../../src";
import { createClient } from "../../../src";

afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
Expand Down
255 changes: 255 additions & 0 deletions spec/integ/crypto/verification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/*
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 { MockResponse } from "fetch-mock";

import { createClient, MatrixClient } from "../../../src";
import { ShowSasCallbacks, VerifierEvent } from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { VerificationBase } from "../../../src/crypto/verification/Base";
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
SIGNED_TEST_DEVICE_DATA,
TEST_DEVICE_ID,
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
TEST_USER_ID,
} from "../../test-utils/test-data";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import {
Phase,
VerificationRequest,
VerificationRequestEvent,
} from "../../../src/crypto/verification/request/VerificationRequest";

// 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.
jest.useFakeTimers();

/**
* Integration tests for verification functionality.
*
* 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.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
// 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;

/** the client under test */
let aliceClient: MatrixClient;

/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: SyncResponder;

beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;

const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: "device_under_test",
});

await initCrypto(aliceClient);
});

afterEach(async () => {
await aliceClient.stopClient();
fetchMock.mockReset();
});

beforeEach(() => {
syncResponder = new SyncResponder(aliceClient.getHomeserverUrl());
mockInitialApiRequests(aliceClient.getHomeserverUrl());
aliceClient.startClient();
});

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,
},
},
});

// 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.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]),
]);
const transactionId = request.channel.transactionId;
expect(transactionId).toBeDefined();
expect(request.phase).toEqual(Phase.Requested);

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(Phase.Ready);

// ... 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(Phase.Started);
expect(request.chosenMethod).toEqual("m.sas.v1");

// there should now be a verifier
const verifier: VerificationBase = request.verifier!;
expect(verifier).toBeDefined();

// 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);
});

// 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}`,
),
},
},
});

// that should satisfy Alice, who should reply with a 'done'
await expectSendToDeviceMessage("m.key.verification.done");

// ... and the whole thing should be done!
await verificationPromise;
expect(request.phase).toEqual(Phase.Done);

// we're done with the temporary keypair
olmSAS.free();
});

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.
*
* @param msgtype - type of to-device message we expect
* @returns A Promise which resolves with the body of the HTTP request
*/
function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }> {
return new Promise((resolve) => {
fetchMock.putOnce(
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp(msgtype)}`),
(url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
return {};
},
);
});
}

/** wait for the verification request to emit a 'Change' event */
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
return new Promise<void>((resolve) => {
request.once(VerificationRequestEvent.Change, resolve);
});
}

/** Perform a MAC calculation on the given data
*
* Does an HKDR and HMAC as defined by the matrix spec (https://spec.matrix.org/v1.7/client-server-api/#mac-calculation,
* as amended by https://github.com/matrix-org/matrix-spec/issues/1553).
*
* @param olmSAS
* @param input
* @param info
*/
function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string {
const mac = olmSAS.calculate_mac_fixed_base64(input, info);
//console.info(`Test MAC: input:'${input}, info: '${info}' -> '${mac}`);
return mac;
}
Loading