1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

Add an integration test for verification (#3436)

* Move existing crypto integ tests into a subdirectory

* Factor out some common bits from `crypto.spec.ts`

* Integration test for device verification

* Ignore generated file in prettier
This commit is contained in:
Richard van der Hoff
2023-06-02 16:01:21 +01:00
committed by GitHub
parent 946a1cef0f
commit 858155e0ef
11 changed files with 470 additions and 35 deletions

View File

@ -24,3 +24,6 @@ out
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier # This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md /CHANGELOG.md
# This file is also autogenerated
/spec/test-utils/test-data/index.ts

View File

@ -18,8 +18,8 @@ import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb"; import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils"; import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../src"; import { createClient, MatrixClient, UIAuthCallback } from "../../../src";
afterEach(() => { afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections // reset fake-indexeddb after each test, to make sure we don't leak connections

View File

@ -21,11 +21,11 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb"; import { IDBFactory } from "fake-indexeddb";
import { MockResponse, MockResponseFunction } from "fetch-mock"; import { MockResponse, MockResponseFunction } from "fetch-mock";
import type { IDeviceKeys } from "../../src/@types/crypto"; import type { IDeviceKeys } from "../../../src/@types/crypto";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../test-utils/test-utils"; import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { TestClient } from "../TestClient"; import { TestClient } from "../../TestClient";
import { logger } from "../../src/logger"; import { logger } from "../../../src/logger";
import { import {
createClient, createClient,
IClaimOTKsResult, IClaimOTKsResult,
@ -43,13 +43,14 @@ import {
Room, Room,
RoomMember, RoomMember,
RoomStateEvent, RoomStateEvent,
} from "../../src/matrix"; } from "../../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver"; import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder"; import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { escapeRegExp } from "../../src/utils"; import { escapeRegExp } from "../../../src/utils";
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter"; import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
import { flushPromises } from "../test-utils/flushPromises"; import { flushPromises } from "../../test-utils/flushPromises";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
const ROOM_ID = "!room:id"; const ROOM_ID = "!room:id";
@ -419,12 +420,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> { async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
logger.log(aliceClient.getUserId() + ": starting"); logger.log(aliceClient.getUserId() + ": starting");
const homeserverUrl = aliceClient.getHomeserverUrl(); mockInitialApiRequests(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",
});
// we let the client do a very basic initial sync, which it needs before // we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys. // it will upload one-time keys.

View File

@ -16,12 +16,12 @@ limitations under the License.
import { Account } from "@matrix-org/olm"; import { Account } from "@matrix-org/olm";
import { logger } from "../../src/logger"; import { logger } from "../../../src/logger";
import { decodeRecoveryKey } from "../../src/crypto/recoverykey"; import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup"; import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
import { TestClient } from "../TestClient"; import { TestClient } from "../../TestClient";
import { IEvent } from "../../src"; import { IEvent } from "../../../src";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
const ROOM_ID = "!ROOM:ID"; const ROOM_ID = "!ROOM:ID";

View File

@ -26,16 +26,16 @@ limitations under the License.
*/ */
// load olm before the sdk if possible // load olm before the sdk if possible
import "../olm-loader"; import "../../olm-loader";
import type { Session } from "@matrix-org/olm"; import type { Session } from "@matrix-org/olm";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto"; import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
import { logger } from "../../src/logger"; import { logger } from "../../../src/logger";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../../test-utils/test-utils";
import { TestClient } from "../TestClient"; import { TestClient } from "../../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client"; import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix"; import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { DeviceInfo } from "../../../src/crypto/deviceinfo";
let aliTestClient: TestClient; let aliTestClient: TestClient;
const roomId = "!room:localhost"; const roomId = "!room:localhost";

View File

@ -17,7 +17,7 @@ limitations under the License.
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb"; import { IDBFactory } from "fake-indexeddb";
import { createClient } from "../../src"; import { createClient } from "../../../src";
afterEach(() => { afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections // reset fake-indexeddb after each test, to make sure we don't leak connections

View File

@ -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;
}

View File

@ -0,0 +1,30 @@
/*
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";
/**
* Mock out the endpoints that the js-sdk calls when we call `MatrixClient.start()`.
*
* @param homeserverUrl - the homeserver url for the client under test
*/
export function mockInitialApiRequests(homeserverUrl: string) {
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
}

1
spec/test-utils/test-data/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/env

View File

@ -0,0 +1,117 @@
#!/bin/env python
#
# 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.
"""
This file is a Python script to generate test data for crypto tests.
To run it:
python -m venv env
./env/bin/pip install cryptography canonicaljson
./env/bin/python generate-test-data.py > index.ts
"""
import base64
import json
from canonicaljson import encode_canonical_json
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# input data
TEST_USER_ID = "@alice:localhost"
TEST_DEVICE_ID = "test_device"
# any 32-byte string can be an ed25519 private key.
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
def main() -> None:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
TEST_DEVICE_PRIVATE_KEY_BYTES
)
b64_public_key = encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
device_data = {
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"device_id": TEST_DEVICE_ID,
"keys": {
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
},
"signatures": {TEST_USER_ID: {}},
"user_id": TEST_USER_ID,
}
device_data["signatures"][TEST_USER_ID][ f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
device_data, private_key
)
print(
f"""\
/* Test data for cryptography tests
*
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
/* eslint-disable comma-dangle */
export const TEST_USER_ID = "{TEST_USER_ID}";
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
""", end='',
)
def encode_base64(input_bytes: bytes) -> str:
"""Encode with unpadded base64"""
output_bytes = base64.b64encode(input_bytes)
output_string = output_bytes.decode("ascii")
return output_string.rstrip("=")
def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
"""
Sign the given json object
Returns the base64-encoded signature of signing `input` following the Matrix
JSON signature algorithm [1]
[1]: https://spec.matrix.org/v1.7/appendices/#signing-details
"""
signatures = json_object.pop("signatures", {})
unsigned = json_object.pop("unsigned", None)
signature = private_key.sign(encode_canonical_json(json_object))
signature_base64 = encode_base64(signature)
json_object["signatures"] = signatures
if unsigned is not None:
json_object["unsigned"] = unsigned
return signature_base64
if __name__ == "__main__":
main()

View File

@ -0,0 +1,33 @@
/* Test data for cryptography tests
*
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { IDeviceKeys } from "../../../src/@types/crypto";
/* eslint-disable comma-dangle */
export const TEST_USER_ID = "@alice:localhost";
export const TEST_DEVICE_ID = "test_device";
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"device_id": "test_device",
"keys": {
"curve25519:test_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
"ed25519:test_device": "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU"
},
"user_id": "@alice:localhost",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "LmQC/yAUZJmkxZ+3L0nEwvtVWOzjqQqADWBhk+C47SPaFYHeV+E291mgXaSCJVeGltX+HC49Aw7nb6ga7sw0Aw"
}
}
};