/* 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 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", () => { 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> { // 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(); }); }); /** 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); }