260 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			260 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
| /*
 | |
| Copyright 2024 New Vector Ltd.
 | |
| Copyright 2023 The Matrix.org Foundation C.I.C.
 | |
| 
 | |
| SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
 | |
| Please see LICENSE files in the repository root for full details.
 | |
| */
 | |
| 
 | |
| import jsQR from "jsqr";
 | |
| 
 | |
| import type { JSHandle, Locator, Page } from "@playwright/test";
 | |
| import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
 | |
| import { test, expect } from "../../element-web-test";
 | |
| import {
 | |
|     awaitVerifier,
 | |
|     checkDeviceIsConnectedKeyBackup,
 | |
|     checkDeviceIsCrossSigned,
 | |
|     doTwoWaySasVerification,
 | |
|     logIntoElement,
 | |
|     waitForVerificationRequest,
 | |
| } from "./utils";
 | |
| import { Bot } from "../../pages/bot";
 | |
| 
 | |
| test.describe("Device verification", { tag: "@no-webkit" }, () => {
 | |
|     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");
 | |
| 
 | |
|         // wait for the page to load
 | |
|         await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
 | |
| 
 | |
|         // Create a new device for alice
 | |
|         aliceBotClient = new Bot(page, homeserver, {
 | |
|             bootstrapCrossSigning: true,
 | |
|             bootstrapSecretStorage: true,
 | |
|         });
 | |
|         aliceBotClient.setCredentials(credentials);
 | |
| 
 | |
|         // Backup is prepared in the background. Poll until it is ready.
 | |
|         const botClientHandle = await aliceBotClient.prepareClient();
 | |
|         await expect
 | |
|             .poll(async () => {
 | |
|                 expectedBackupVersion = await botClientHandle.evaluate((cli) =>
 | |
|                     cli.getCrypto()!.getActiveSessionBackupVersion(),
 | |
|                 );
 | |
|                 return expectedBackupVersion;
 | |
|             })
 | |
|             .not.toBe(null);
 | |
|     });
 | |
| 
 | |
|     // 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, 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, 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 Uint8ClampedArray(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, 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, 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, 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();
 | |
|     });
 | |
| });
 | |
| 
 | |
| /** 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);
 | |
| }
 |