362 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			362 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
| /*
 | |
| 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 jsQR from "jsqr";
 | |
| 
 | |
| import type { JSHandle, Locator, Page } from "@playwright/test";
 | |
| import type { Preset, Visibility } from "matrix-js-sdk/src/matrix";
 | |
| import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api";
 | |
| import { test, expect } from "../../element-web-test";
 | |
| import {
 | |
|     checkDeviceIsConnectedKeyBackup,
 | |
|     checkDeviceIsCrossSigned,
 | |
|     doTwoWaySasVerification,
 | |
|     logIntoElement,
 | |
|     waitForVerificationRequest,
 | |
| } from "./utils";
 | |
| import { Client } from "../../pages/client";
 | |
| import { Bot } from "../../pages/bot";
 | |
| 
 | |
| test.describe("Device verification", () => {
 | |
|     let aliceBotClient: Bot;
 | |
| 
 | |
|     /** The backup version that was set up by the bot client. */
 | |
|     let expectedBackupVersion: string;
 | |
| 
 | |
|     test.beforeEach(async ({ page, homeserver, credentials }) => {
 | |
|         // Visit the login page of the app, to load the matrix sdk
 | |
|         await page.goto("/#/login");
 | |
| 
 | |
|         await page.pause();
 | |
| 
 | |
|         // wait for the page to load
 | |
|         await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
 | |
| 
 | |
|         // Create a new device for alice
 | |
|         aliceBotClient = new Bot(page, homeserver, {
 | |
|             rustCrypto: true,
 | |
|             bootstrapCrossSigning: true,
 | |
|             bootstrapSecretStorage: true,
 | |
|         });
 | |
|         aliceBotClient.setCredentials(credentials);
 | |
|         const mxClientHandle = await aliceBotClient.prepareClient();
 | |
| 
 | |
|         await page.waitForTimeout(20000);
 | |
| 
 | |
|         expectedBackupVersion = await mxClientHandle.evaluate(async (mxClient) => {
 | |
|             return await mxClient.getCrypto()!.getActiveSessionBackupVersion();
 | |
|         });
 | |
|     });
 | |
| 
 | |
|     // Click the "Verify with another device" button, and have the bot client auto-accept it.
 | |
|     async function initiateAliceVerificationRequest(page: Page): Promise<JSHandle<VerificationRequest>> {
 | |
|         // alice bot waits for verification request
 | |
|         const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient);
 | |
| 
 | |
|         // Click on "Verify with another device"
 | |
|         await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click();
 | |
| 
 | |
|         // alice bot responds yes to verification request from alice
 | |
|         return promiseVerificationRequest;
 | |
|     }
 | |
| 
 | |
|     test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
 | |
|         await logIntoElement(page, homeserver, credentials);
 | |
| 
 | |
|         // Launch the verification request between alice and the bot
 | |
|         const verificationRequest = await initiateAliceVerificationRequest(page);
 | |
| 
 | |
|         // Handle emoji SAS verification
 | |
|         const infoDialog = page.locator(".mx_InfoDialog");
 | |
|         // the bot chooses to do an emoji verification
 | |
|         const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
 | |
| 
 | |
|         // Handle emoji request and check that emojis are matching
 | |
|         await doTwoWaySasVerification(page, verifier);
 | |
| 
 | |
|         await infoDialog.getByRole("button", { name: "They match" }).click();
 | |
|         await infoDialog.getByRole("button", { name: "Got it" }).click();
 | |
| 
 | |
|         // Check that our device is now cross-signed
 | |
|         await checkDeviceIsCrossSigned(app);
 | |
| 
 | |
|         // Check that the current device is connected to key backup
 | |
|         // For now we don't check that the backup key is in cache because it's a bit flaky,
 | |
|         // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
 | |
|         await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
 | |
|     });
 | |
| 
 | |
|     test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
 | |
|         // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
 | |
|         await logIntoElement(page, homeserver, credentials);
 | |
| 
 | |
|         // Launch the verification request between alice and the bot
 | |
|         const verificationRequest = await initiateAliceVerificationRequest(page);
 | |
| 
 | |
|         const infoDialog = page.locator(".mx_InfoDialog");
 | |
|         // feed the QR code into the verification request.
 | |
|         const qrData = await readQrCode(infoDialog);
 | |
|         const verifier = await verificationRequest.evaluateHandle(
 | |
|             (request, qrData) => request.scanQRCode(new Uint8Array(qrData)),
 | |
|             [...qrData],
 | |
|         );
 | |
| 
 | |
|         // Confirm that the bot user scanned successfully
 | |
|         await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible();
 | |
|         await infoDialog.getByRole("button", { name: "Yes" }).click();
 | |
|         await infoDialog.getByRole("button", { name: "Got it" }).click();
 | |
| 
 | |
|         // wait for the bot to see we have finished
 | |
|         await verifier.evaluate((verifier) => verifier.verify());
 | |
| 
 | |
|         // the bot uploads the signatures asynchronously, so wait for that to happen
 | |
|         await page.waitForTimeout(1000);
 | |
| 
 | |
|         // our device should trust the bot device
 | |
|         await app.client.evaluate(async (cli, aliceBotCredentials) => {
 | |
|             const deviceStatus = await cli
 | |
|                 .getCrypto()!
 | |
|                 .getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId);
 | |
|             if (!deviceStatus.isVerified()) {
 | |
|                 throw new Error("Bot device was not verified after QR code verification");
 | |
|             }
 | |
|         }, aliceBotClient.credentials);
 | |
| 
 | |
|         // Check that our device is now cross-signed
 | |
|         await checkDeviceIsCrossSigned(app);
 | |
| 
 | |
|         // Check that the current device is connected to key backup
 | |
|         // For now we don't check that the backup key is in cache because it's a bit flaky,
 | |
|         // as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
 | |
|         await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
 | |
|     });
 | |
| 
 | |
|     test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
 | |
|         await logIntoElement(page, homeserver, credentials);
 | |
| 
 | |
|         // Select the security phrase
 | |
|         await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
 | |
| 
 | |
|         // Fill the passphrase
 | |
|         const dialog = page.locator(".mx_Dialog");
 | |
|         await dialog.locator("input").fill("new passphrase");
 | |
|         await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
 | |
| 
 | |
|         await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
 | |
| 
 | |
|         // Check that our device is now cross-signed
 | |
|         await checkDeviceIsCrossSigned(app);
 | |
| 
 | |
|         // Check that the current device is connected to key backup
 | |
|         // The backup decryption key should be in cache also, as we got it directly from the 4S
 | |
|         await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
 | |
|     });
 | |
| 
 | |
|     test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
 | |
|         await logIntoElement(page, homeserver, credentials);
 | |
| 
 | |
|         // Select the security phrase
 | |
|         await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
 | |
| 
 | |
|         // Fill the security key
 | |
|         const dialog = page.locator(".mx_Dialog");
 | |
|         await dialog.getByRole("button", { name: "use your Security Key" }).click();
 | |
|         const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
 | |
|         await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
 | |
|         await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
 | |
| 
 | |
|         await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
 | |
| 
 | |
|         // Check that our device is now cross-signed
 | |
|         await checkDeviceIsCrossSigned(app);
 | |
| 
 | |
|         // Check that the current device is connected to key backup
 | |
|         // The backup decryption key should be in cache also, as we got it directly from the 4S
 | |
|         await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
 | |
|     });
 | |
| 
 | |
|     test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
 | |
|         await logIntoElement(page, homeserver, credentials);
 | |
| 
 | |
|         /* Dismiss "Verify this device" */
 | |
|         const authPage = page.locator(".mx_AuthPage");
 | |
|         await authPage.getByRole("button", { name: "Skip verification for now" }).click();
 | |
|         await authPage.getByRole("button", { name: "I'll verify later" }).click();
 | |
| 
 | |
|         await page.waitForSelector(".mx_MatrixChat");
 | |
|         const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
 | |
| 
 | |
|         /* Now initiate a verification request from the *bot* device. */
 | |
|         const botVerificationRequest = await aliceBotClient.evaluateHandle(
 | |
|             async (client, { userId, deviceId }) => {
 | |
|                 return client.getCrypto()!.requestDeviceVerification(userId, deviceId);
 | |
|             },
 | |
|             { userId: credentials.userId, deviceId: elementDeviceId },
 | |
|         );
 | |
| 
 | |
|         /* Check the toast for the incoming request */
 | |
|         const toast = await toasts.getToast("Verification requested");
 | |
|         // it should contain the device ID of the requesting device
 | |
|         await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible();
 | |
|         // Accept
 | |
|         await toast.getByRole("button", { name: "Verify Session" }).click();
 | |
| 
 | |
|         /* Click 'Start' to start SAS verification */
 | |
|         await page.getByRole("button", { name: "Start" }).click();
 | |
| 
 | |
|         /* on the bot side, wait for the verifier to exist ... */
 | |
|         const verifier = await awaitVerifier(botVerificationRequest);
 | |
|         // ... confirm ...
 | |
|         botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify());
 | |
|         // ... and then check the emoji match
 | |
|         await doTwoWaySasVerification(page, verifier);
 | |
| 
 | |
|         /* And we're all done! */
 | |
|         const infoDialog = page.locator(".mx_InfoDialog");
 | |
|         await infoDialog.getByRole("button", { name: "They match" }).click();
 | |
|         await expect(
 | |
|             infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`),
 | |
|         ).toBeVisible();
 | |
|         await infoDialog.getByRole("button", { name: "Got it" }).click();
 | |
|     });
 | |
| });
 | |
| 
 | |
| test.describe("User verification", () => {
 | |
|     // note that there are other tests that check user verification works in `crypto.spec.ts`.
 | |
| 
 | |
|     test.use({
 | |
|         displayName: "Alice",
 | |
|         botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" },
 | |
|     });
 | |
| 
 | |
|     test("can receive a verification request when there is no existing DM", async ({
 | |
|         page,
 | |
|         app,
 | |
|         bot: bob,
 | |
|         user: aliceCredentials,
 | |
|         toasts,
 | |
|     }) => {
 | |
|         await app.client.bootstrapCrossSigning(aliceCredentials);
 | |
| 
 | |
|         // the other user creates a DM
 | |
|         const dmRoomId = await createDMRoom(bob, aliceCredentials.userId);
 | |
| 
 | |
|         // accept the DM
 | |
|         await app.viewRoomByName("Bob");
 | |
|         await page.getByRole("button", { name: "Start chatting" }).click();
 | |
| 
 | |
|         // once Alice has joined, Bob starts the verification
 | |
|         const bobVerificationRequest = await bob.evaluateHandle(
 | |
|             async (client, { dmRoomId, aliceCredentials }) => {
 | |
|                 const room = client.getRoom(dmRoomId);
 | |
|                 while (room.getMember(aliceCredentials.userId)?.membership !== "join") {
 | |
|                     await new Promise((resolve) => {
 | |
|                         room.once(window.matrixcs.RoomStateEvent.Members, resolve);
 | |
|                     });
 | |
|                 }
 | |
| 
 | |
|                 return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId);
 | |
|             },
 | |
|             { dmRoomId, aliceCredentials },
 | |
|         );
 | |
| 
 | |
|         // there should also be a toast
 | |
|         const toast = await toasts.getToast("Verification requested");
 | |
|         // it should contain the details of the requesting user
 | |
|         await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible();
 | |
|         // Accept
 | |
|         await toast.getByRole("button", { name: "Verify Session" }).click();
 | |
| 
 | |
|         // request verification by emoji
 | |
|         await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click();
 | |
| 
 | |
|         /* on the bot side, wait for the verifier to exist ... */
 | |
|         const botVerifier = await awaitVerifier(bobVerificationRequest);
 | |
|         // ... confirm ...
 | |
|         botVerifier.evaluate((verifier) => verifier.verify());
 | |
|         // ... and then check the emoji match
 | |
|         await doTwoWaySasVerification(page, botVerifier);
 | |
| 
 | |
|         await page.getByRole("button", { name: "They match" }).click();
 | |
|         await expect(page.getByText("You've successfully verified Bob!")).toBeVisible();
 | |
|         await page.getByRole("button", { name: "Got it" }).click();
 | |
|     });
 | |
| });
 | |
| 
 | |
| /** Extract the qrcode out of an on-screen html element */
 | |
| async function readQrCode(base: Locator) {
 | |
|     const qrCode = base.locator('[alt="QR Code"]');
 | |
|     const imageData = await qrCode.evaluate<
 | |
|         {
 | |
|             colorSpace: PredefinedColorSpace;
 | |
|             width: number;
 | |
|             height: number;
 | |
|             buffer: number[];
 | |
|         },
 | |
|         HTMLImageElement
 | |
|     >(async (img) => {
 | |
|         // draw the image on a canvas
 | |
|         const myCanvas = new OffscreenCanvas(img.width, img.height);
 | |
|         const ctx = myCanvas.getContext("2d");
 | |
|         ctx.drawImage(img, 0, 0);
 | |
| 
 | |
|         // read the image data
 | |
|         const imageData = ctx.getImageData(0, 0, myCanvas.width, myCanvas.height);
 | |
|         return {
 | |
|             colorSpace: imageData.colorSpace,
 | |
|             width: imageData.width,
 | |
|             height: imageData.height,
 | |
|             buffer: [...new Uint8ClampedArray(imageData.data.buffer)],
 | |
|         };
 | |
|     });
 | |
| 
 | |
|     // now we can decode the QR code.
 | |
|     const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height);
 | |
|     return new Uint8Array(result.binaryData);
 | |
| }
 | |
| 
 | |
| async function createDMRoom(client: Client, userId: string): Promise<string> {
 | |
|     return client.createRoom({
 | |
|         preset: "trusted_private_chat" as Preset,
 | |
|         visibility: "private" as Visibility,
 | |
|         invite: [userId],
 | |
|         is_direct: true,
 | |
|         initial_state: [
 | |
|             {
 | |
|                 type: "m.room.encryption",
 | |
|                 state_key: "",
 | |
|                 content: {
 | |
|                     algorithm: "m.megolm.v1.aes-sha2",
 | |
|                 },
 | |
|             },
 | |
|         ],
 | |
|     });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Wait for a verifier to exist for a VerificationRequest
 | |
|  *
 | |
|  * @param botVerificationRequest
 | |
|  */
 | |
| async function awaitVerifier(botVerificationRequest: JSHandle<VerificationRequest>): Promise<JSHandle<Verifier>> {
 | |
|     return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
 | |
|         while (!verificationRequest.verifier) {
 | |
|             await new Promise((r) => verificationRequest.once("change" as any, r));
 | |
|         }
 | |
|         return verificationRequest.verifier;
 | |
|     });
 | |
| }
 |