diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 64a43d1ce..fdcc4bf1a 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -18,12 +18,14 @@ 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 { ShowQrCodeCallbacks, 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 { + MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, + SIGNED_CROSS_SIGNING_KEYS_DATA, SIGNED_TEST_DEVICE_DATA, TEST_DEVICE_ID, TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, @@ -40,6 +42,34 @@ import { // to ensure that we don't end up with dangling timeouts. jest.useFakeTimers(); +let previousCrypto: Crypto | undefined; + +beforeAll(() => { + // Stub out global.crypto + previousCrypto = global["crypto"]; + + Object.defineProperty(global, "crypto", { + value: { + getRandomValues: function (array: T): T { + array.fill(0x12); + return array; + }, + }, + }); +}); + +// restore the original global.crypto +afterAll(() => { + if (previousCrypto === undefined) { + // @ts-ignore deleting a non-optional property. It *is* optional really. + delete global.crypto; + } else { + Object.defineProperty(global, "crypto", { + value: previousCrypto, + }); + } +}); + /** * Integration tests for verification functionality. * @@ -208,6 +238,107 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st olmSAS.free(); }); + 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, + }, + }, + ...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 + // user will be one). + syncResponder.sendOrQueueSyncResponse({}); + // DeviceList has a sleep(5) which we need to make happen + await jest.advanceTimersByTimeAsync(10); + expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy(); + + // have alice initiate a verification. She should send a m.key.verification.request + const [requestBody, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]), + ]); + const transactionId = request.channel.transactionId; + + 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"); + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + + // The dummy device replies with an m.key.verification.ready, with an indication we can scan the QR code + returnToDeviceMessageFromSync({ + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.qr_code.scan.v1"], + transaction_id: transactionId, + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(Phase.Ready); + + // we should now have QR data we can display + const qrCodeData = request.qrCodeData!; + expect(qrCodeData).toBeTruthy(); + const qrCodeBuffer = qrCodeData.getBuffer(); + // https://spec.matrix.org/v1.7/client-server-api/#qr-code-format + expect(qrCodeBuffer.subarray(0, 6).toString("latin1")).toEqual("MATRIX"); + expect(qrCodeBuffer.readUint8(6)).toEqual(0x02); // version + expect(qrCodeBuffer.readUint8(7)).toEqual(0x02); // mode + const txnIdLen = qrCodeBuffer.readUint16BE(8); + expect(qrCodeBuffer.subarray(10, 10 + txnIdLen).toString("utf-8")).toEqual(transactionId); + // Alice's device's public key comes next, but we have nothing to do with it here. + // const aliceDevicePubKey = qrCodeBuffer.subarray(10 + txnIdLen, 32 + 10 + txnIdLen); + expect(qrCodeBuffer.subarray(42 + txnIdLen, 32 + 42 + txnIdLen)).toEqual( + Buffer.from(MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, "base64"), + ); + const sharedSecret = qrCodeBuffer.subarray(74 + txnIdLen); + + // the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start" + returnToDeviceMessageFromSync({ + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.reciprocate.v1", + transaction_id: transactionId, + secret: encodeUnpaddedBase64(sharedSecret), + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(Phase.Started); + expect(request.chosenMethod).toEqual("m.reciprocate.v1"); + + // there should now be a verifier + const verifier: VerificationBase = request.verifier!; + expect(verifier).toBeDefined(); + + // ... which we call .verify on, which emits a ShowReciprocateQr event + const verificationPromise = verifier.verify(); + const reciprocateQRCodeCallbacks = await new Promise((resolve) => { + verifier.once(VerifierEvent.ShowReciprocateQr, resolve); + }); + + // Alice confirms she is happy + reciprocateQRCodeCallbacks.confirm(); + + // 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); + }, + ); + function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); @@ -253,3 +384,7 @@ function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string { //console.info(`Test MAC: input:'${input}, info: '${info}' -> '${mac}`); return mac; } + +function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { + return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, ""); +} diff --git a/spec/test-utils/test-data/generate-test-data.py b/spec/test-utils/test-data/generate-test-data.py index f5eae004e..3ba7dd4a8 100755 --- a/spec/test-utils/test-data/generate-test-data.py +++ b/spec/test-utils/test-data/generate-test-data.py @@ -37,6 +37,10 @@ TEST_DEVICE_ID = "test_device" # any 32-byte string can be an ed25519 private key. TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef" +MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale" +USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser" +SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself" + def main() -> None: private_key = ed25519.Ed25519PrivateKey.from_private_bytes( @@ -57,10 +61,17 @@ def main() -> None: "user_id": TEST_USER_ID, } - device_data["signatures"][TEST_USER_ID][ f"ed25519:{TEST_DEVICE_ID}"] = sign_json( + device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json( device_data, private_key ) + master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( + MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES + ) + b64_master_public_key = encode_base64( + master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + ) + print( f"""\ /* Test data for cryptography tests @@ -69,6 +80,7 @@ def main() -> None: */ import {{ IDeviceKeys }} from "../../../src/@types/crypto"; +import {{ IDownloadKeyResult }} from "../../../src"; /* eslint-disable comma-dangle */ @@ -80,10 +92,84 @@ 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='', + +/** base64-encoded public master cross-signing key */ +export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}"; + +/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */ +export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial = { + json.dumps(build_cross_signing_keys_data(), indent=4) +}; +""", + end="", ) +def build_cross_signing_keys_data() -> dict: + """Build the signed cross-signing-keys data for return from /keys/query""" + master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( + MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES + ) + b64_master_public_key = encode_base64( + master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + ) + self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( + SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES + ) + b64_self_signing_public_key = encode_base64( + self_signing_private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) + ) + user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes( + USER_CROSS_SIGNING_PRIVATE_KEY_BYTES + ) + b64_user_signing_public_key = encode_base64( + user_signing_private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) + ) + # create without signatures initially + cross_signing_keys_data = { + "master_keys": { + TEST_USER_ID: { + "keys": { + f"ed25519:{b64_master_public_key}": b64_master_public_key, + }, + "user_id": TEST_USER_ID, + "usage": ["master"], + } + }, + "self_signing_keys": { + TEST_USER_ID: { + "keys": { + f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key, + }, + "user_id": TEST_USER_ID, + "usage": ["self_signing"], + }, + }, + "user_signing_keys": { + TEST_USER_ID: { + "keys": { + f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key, + }, + "user_id": TEST_USER_ID, + "usage": ["user_signing"], + }, + }, + } + # sign the sub-keys with the master + for k in ["self_signing_keys", "user_signing_keys"]: + to_sign = cross_signing_keys_data[k][TEST_USER_ID] + sig = sign_json(to_sign, master_private_key) + to_sign["signatures"] = { + TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig} + } + + return cross_signing_keys_data + + def encode_base64(input_bytes: bytes) -> str: """Encode with unpadded base64""" output_bytes = base64.b64encode(input_bytes) diff --git a/spec/test-utils/test-data/index.ts b/spec/test-utils/test-data/index.ts index fbb9a1c2b..07ff12225 100644 --- a/spec/test-utils/test-data/index.ts +++ b/spec/test-utils/test-data/index.ts @@ -4,6 +4,7 @@ */ import { IDeviceKeys } from "../../../src/@types/crypto"; +import { IDownloadKeyResult } from "../../../src"; /* eslint-disable comma-dangle */ @@ -31,3 +32,53 @@ export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = { } } }; + +/** base64-encoded public master cross-signing key */ +export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY"; + +/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */ +export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial = { + "master_keys": { + "@alice:localhost": { + "keys": { + "ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY" + }, + "user_id": "@alice:localhost", + "usage": [ + "master" + ] + } + }, + "self_signing_keys": { + "@alice:localhost": { + "keys": { + "ed25519:aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY": "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY" + }, + "user_id": "@alice:localhost", + "usage": [ + "self_signing" + ], + "signatures": { + "@alice:localhost": { + "ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "XfhYEhZmOs8BJdb3viatILBZ/bElsHXEW28V4tIaY5CxrBR0YOym3yZHWmRmypXessHZAKOhZn3yBMXzdajyCw" + } + } + } + }, + "user_signing_keys": { + "@alice:localhost": { + "keys": { + "ed25519:g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY": "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY" + }, + "user_id": "@alice:localhost", + "usage": [ + "user_signing" + ], + "signatures": { + "@alice:localhost": { + "ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "6AkD1XM2H0/ebgP9oBdMKNeft7uxsrb0XN1CsjjHgeZCvCTMmv3BHlLiT/Hzy4fe8H+S1tr484dcXN/PIdnfDA" + } + } + } + } +};