From 5104d53ddfcc92c451745a85d64db910cc3a46ab Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Dec 2023 08:55:29 +0000 Subject: [PATCH] Migrate remaining crypto tests from Cypress to Playwright (#12021) * Fix bot MatrixClient being set up multiple times Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate verification.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate crypto.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add screenshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Record trace on-first-retry Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Don't start client when not needed Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add bot log prefixing Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Turns out we need these Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix crypto tests in rust crypto Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- cypress/e2e/crypto/crypto.spec.ts | 553 ------------------ cypress/e2e/crypto/utils.ts | 243 -------- cypress/e2e/crypto/verification.spec.ts | 429 -------------- cypress/support/client.ts | 22 - playwright.config.ts | 1 + playwright/e2e/crypto/crypto.spec.ts | 487 +++++++++++++++ playwright/e2e/crypto/utils.ts | 187 +++++- playwright/e2e/crypto/verification.spec.ts | 348 +++++++++++ playwright/element-web-test.ts | 2 +- playwright/pages/ElementAppPage.ts | 6 +- playwright/pages/bot.ts | 50 +- playwright/pages/client.ts | 42 +- ...omSummaryCard-with-verified-e2ee-linux.png | Bin 0 -> 27177 bytes 13 files changed, 1111 insertions(+), 1259 deletions(-) delete mode 100644 cypress/e2e/crypto/crypto.spec.ts delete mode 100644 cypress/e2e/crypto/utils.ts delete mode 100644 cypress/e2e/crypto/verification.spec.ts create mode 100644 playwright/e2e/crypto/crypto.spec.ts create mode 100644 playwright/e2e/crypto/verification.spec.ts create mode 100644 playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts deleted file mode 100644 index 4680a4b0868..00000000000 --- a/cypress/e2e/crypto/crypto.spec.ts +++ /dev/null @@ -1,553 +0,0 @@ -/* -Copyright 2022 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 type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; -import type { CypressBot } from "../../support/bot"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { - createSharedRoomWithUser, - doTwoWaySasVerification, - downloadKey, - enableKeyBackup, - logIntoElement, - logOutOfElement, - waitForVerificationRequest, -} from "./utils"; - -interface CryptoTestContext extends Mocha.Context { - homeserver: HomeserverInstance; - bob: CypressBot; -} - -const openRoomInfo = () => { - cy.findByRole("button", { name: "Room info" }).click(); - return cy.get(".mx_RightPanel"); -}; - -const checkDMRoom = () => { - cy.get(".mx_RoomView_body").within(() => { - cy.findByText("Alice created this DM.").should("exist"); - cy.findByText("Alice invited Bob", { timeout: 1000 }).should("exist"); - - cy.get(".mx_cryptoEvent").within(() => { - cy.findByText("Encryption enabled").should("exist"); - }); - }); -}; - -const startDMWithBob = function (this: CryptoTestContext) { - cy.get(".mx_RoomList").within(() => { - cy.findByRole("button", { name: "Start chat" }).click(); - }); - cy.findByTestId("invite-dialog-input").type(this.bob.getUserId()); - cy.get(".mx_InviteDialog_tile_nameStack_name").within(() => { - cy.findByText("Bob").click(); - }); - cy.get(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").within(() => { - cy.findByText("Bob").should("exist"); - }); - cy.findByRole("button", { name: "Go" }).click(); -}; - -const testMessages = function (this: CryptoTestContext) { - // check the invite message - cy.findByText("Hey!") - .closest(".mx_EventTile") - .within(() => { - cy.get(".mx_EventTile_e2eIcon_warning").should("not.exist"); - }); - - // Bob sends a response - cy.get("@bobsRoom").then((room) => { - this.bob.sendTextMessage(room.roomId, "Hoo!"); - }); - cy.findByText("Hoo!").closest(".mx_EventTile").should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); -}; - -const bobJoin = function (this: CryptoTestContext) { - cy.window({ log: false }) - .then(async (win) => { - const bobRooms = this.bob.getRooms(); - if (!bobRooms.length) { - await new Promise((resolve) => { - const onMembership = (_event) => { - this.bob.off(win.matrixcs.RoomMemberEvent.Membership, onMembership); - resolve(); - }; - this.bob.on(win.matrixcs.RoomMemberEvent.Membership, onMembership); - }); - } - }) - .then(() => { - cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); - }); - - cy.findByText("Bob joined the room").should("exist"); -}; - -/** configure the given MatrixClient to auto-accept any invites */ -function autoJoin(client: MatrixClient) { - cy.window({ log: false }).then(async (win) => { - client.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { - if (member.membership === "invite" && member.userId === client.getUserId()) { - client.joinRoom(member.roomId); - } - }); - }); -} - -const verify = function (this: CryptoTestContext) { - const bobsVerificationRequestPromise = waitForVerificationRequest(this.bob); - - openRoomInfo().within(() => { - cy.findByRole("menuitem", { name: "People" }).click(); - cy.findByText("Bob").click(); - cy.findByRole("button", { name: "Verify" }).click(); - cy.findByRole("button", { name: "Start Verification" }).click(); - - // this requires creating a DM, so can take a while. Give it a longer timeout. - cy.findByRole("button", { name: "Verify by emoji", timeout: 30000 }).click(); - - cy.wrap(bobsVerificationRequestPromise).then(async (request: VerificationRequest) => { - // the bot user races with the Element user to hit the "verify by emoji" button - const verifier = await request.startVerification("m.sas.v1"); - doTwoWaySasVerification(verifier); - }); - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText("You've successfully verified Bob!").should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); -}; - -describe("Cryptography", function () { - let aliceCredentials: UserCredentials; - let homeserver: HomeserverInstance; - let bob: CypressBot; - - beforeEach(function () { - cy.startHomeserver("default") - .as("homeserver") - .then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { - aliceCredentials = credentials; - }); - return cy.getBot(homeserver, { - displayName: "Bob", - autoAcceptInvites: false, - userIdPrefix: "bob_", - }); - }) - .as("bob") - .then((data) => { - bob = data; - }); - }); - - afterEach(function (this: CryptoTestContext) { - cy.stopHomeserver(this.homeserver); - }); - - for (const isDeviceVerified of [true, false]) { - it(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => { - /** - * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server - * @param keyType - */ - function verifyKey(keyType: string) { - return cy - .getClient() - .then((cli) => cy.wrap(cli.getAccountDataFromServer(`m.cross_signing.${keyType}`))) - .then((accountData: { encrypted: Record> }) => { - expect(accountData.encrypted).to.exist; - const keys = Object.keys(accountData.encrypted); - const key = accountData.encrypted[keys[0]]; - expect(key.ciphertext).to.exist; - expect(key.iv).to.exist; - expect(key.mac).to.exist; - }); - } - - it("by recovery code", () => { - // Verified the device - if (isDeviceVerified) { - cy.bootstrapCrossSigning(aliceCredentials); - } - - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Recovery key is selected by default - cy.findByRole("button", { name: "Continue" }).click(); - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey"); - - downloadKey(); - - // When the device is verified, the `Setting up keys` step is skipped - if (!isDeviceVerified) { - cy.get(".mx_InteractiveAuthDialog").within(() => { - cy.get(".mx_Dialog_title").within(() => { - cy.findByText("Setting up keys").should("exist"); - cy.findByText("Setting up keys").should("not.exist"); - }); - }); - } - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); - - // Verify that the SSSS keys are in the account data stored in the server - verifyKey("master"); - verifyKey("self_signing"); - verifyKey("user_signing"); - }); - - it("by passphrase", () => { - // Verified the device - if (isDeviceVerified) { - cy.bootstrapCrossSigning(aliceCredentials); - } - - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Select passphrase option - cy.findByText("Enter a Security Phrase").click(); - cy.findByRole("button", { name: "Continue" }).click(); - - // Fill passphrase input - cy.get("input").type("new passphrase for setting up a secure key backup"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - // Confirm passphrase - cy.get("input").type("new passphrase for setting up a secure key backup"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - - downloadKey(); - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); - - // Verify that the SSSS keys are in the account data stored in the server - verifyKey("master"); - verifyKey("self_signing"); - verifyKey("user_signing"); - }); - }); - } - - it("creating a DM should work, being e2e-encrypted / user verification", function (this: CryptoTestContext) { - cy.bootstrapCrossSigning(aliceCredentials); - startDMWithBob.call(this); - // send first message - cy.findByRole("textbox", { name: "Send a messageā€¦" }).type("Hey!{enter}"); - checkDMRoom(); - bobJoin.call(this); - testMessages.call(this); - verify.call(this); - - // Assert that verified icon is rendered - cy.findByRole("button", { name: "Room members" }).click(); - cy.findByRole("button", { name: "Room information" }).click(); - cy.get('.mx_RoomSummaryCard_badges [data-kind="success"]').should("contain.text", "Encrypted"); - - // Take a snapshot of RoomSummaryCard with a verified E2EE icon - cy.get(".mx_RightPanel").percySnapshotElement("RoomSummaryCard - with a verified E2EE icon", { - widths: [264], // Emulate the UI. The value is based on minWidth specified on MainSplit.tsx - }); - }); - - it("should allow verification when there is no existing DM", function (this: CryptoTestContext) { - cy.bootstrapCrossSigning(aliceCredentials); - autoJoin(this.bob); - - // we need to have a room with the other user present, so we can open the verification panel - createSharedRoomWithUser(this.bob.getUserId()); - verify.call(this); - }); - - describe("event shields", () => { - let testRoomId: string; - - beforeEach(() => { - cy.bootstrapCrossSigning(aliceCredentials); - autoJoin(bob); - - // create an encrypted room - createSharedRoomWithUser(bob.getUserId()) - .as("testRoomId") - .then((roomId) => { - testRoomId = roomId; - - // enable encryption - cy.getClient().then((cli) => { - cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); - }); - }); - }); - - it("should show the correct shield on e2e events", function (this: CryptoTestContext) { - // Bob has a second, not cross-signed, device - let bobSecondDevice: MatrixClient; - cy.loginBot(homeserver, bob.getUserId(), bob.__cypress_password, {}).then(async (data) => { - bobSecondDevice = data; - }); - - /* Should show an error for a decryption failure */ - cy.log("Testing decryption failure"); - - cy.wrap(0) - .then(() => - bob.sendEvent(testRoomId, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }), - ) - .then((resp) => cy.log(`Bob sent undecryptable event ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "Unable to decrypt message") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_decryption_failure") - .should("have.attr", "aria-label", "This message could not be decrypted"); - - /* Should show a red padlock for an unencrypted message in an e2e room */ - cy.log("Testing unencrypted message"); - cy.wrap(0) - .then(() => - bob.http.authedRequest( - // @ts-ignore-next this wants a Method instance, but that is hard to get to here - "PUT", - `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, - undefined, - { - msgtype: "m.text", - body: "test unencrypted", - }, - ), - ) - .then((resp) => cy.log(`Bob sent unencrypted event with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test unencrypted") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_warning") - .should("have.attr", "aria-label", "Not encrypted"); - - /* Should show no padlock for an unverified user */ - cy.log("Testing message from unverified user"); - - // bob sends a valid event - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) - .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); - - // the message should appear, decrypted, with no warning, but also no "verified" - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* Now verify Bob */ - cy.log("Verifying Bob"); - - verify.call(this); - - /* Existing message should be updated when user is verified. */ - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // still no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* should show no padlock, and be verified, for a message from a verified device */ - cy.log("Testing message from verified device"); - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 2")) - .then((resp) => cy.log(`Bob sent second message from primary device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 2") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - /* should show red padlock for a message from an unverified device */ - cy.log("Testing message from unverified device of verified user"); - cy.wrap(0) - .then(() => bobSecondDevice.sendTextMessage(testRoomId, "test encrypted from unverified")) - .then((resp) => cy.log(`Bob sent message from unverified device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted from unverified") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_warning") - .should("have.attr", "aria-label", "Encrypted by a device not verified by its owner."); - - /* Should show a grey padlock for a message from an unknown device */ - cy.log("Testing message from unknown device"); - - // bob deletes his second device - cy.wrap(0) - .then(() => bobSecondDevice.logout(true)) - .then(() => cy.log(`Bob logged out second device`)); - - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - function awaitOneDevice(iterations = 1) { - let sessionCountText: string; - cy.get(".mx_RightPanel") - .within(() => { - cy.findByRole("button", { name: "Room members" }).click(); - cy.findByText("Bob").click(); - return cy - .get(".mx_UserInfo_devices") - .findByText(" session", { exact: false }) - .then((data) => { - sessionCountText = data.text(); - }); - }) - .then(() => { - cy.log(`At ${new Date().toISOString()}: Bob has '${sessionCountText}'`); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - awaitOneDevice(iterations + 1); - } - }); - } - - awaitOneDevice(); - - // close and reopen the room, to get the shield to update. - cy.viewRoomByName("Bob"); - cy.viewRoomByName("TestRoom"); - - // some debate over whether this should have a red or a grey shield. Legacy crypto shows a grey shield, - // Rust crypto a red one. - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted from unverified") - .find(".mx_EventTile_e2eIcon") - //.should("have.class", "mx_EventTile_e2eIcon_normal") - .should("have.attr", "aria-label", "Encrypted by an unknown or deleted device."); - }); - - it("Should show a grey padlock for a key restored from backup", () => { - enableKeyBackup(); - - // bob sends a valid event - cy.wrap(0) - .then(() => bob.sendTextMessage(testRoomId, "test encrypted 1")) - .then((resp) => cy.log(`Bob sent message from primary device with event id ${resp.event_id}`)); - - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - // no e2e icon - .should("not.have.descendants", ".mx_EventTile_e2eIcon"); - - // It can take up to 10 seconds for the key to be backed up. We don't really have much option other than - // to wait :/ - cy.wait(10000); - - /* log out, and back in */ - logOutOfElement(); - cy.get("@securityKey").then((securityKey) => { - logIntoElement(homeserver.baseUrl, aliceCredentials.username, aliceCredentials.password, securityKey); - }); - - /* go back to the test room and find Bob's message again */ - cy.viewRoomById(testRoomId); - cy.get(".mx_EventTile_last") - .should("contain", "test encrypted 1") - .find(".mx_EventTile_e2eIcon") - .should("have.class", "mx_EventTile_e2eIcon_normal") - .should( - "have.attr", - "aria-label", - "The authenticity of this encrypted message can't be guaranteed on this device.", - ); - }); - - it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { - // bob has a second, not cross-signed, device - cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); - - // verify Bob - verify.call(this); - - cy.get("@testRoomId").then((roomId) => { - // bob sends a valid event - cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); - - // the message should appear, decrypted, with no warning - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hoo!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - - // bob sends an edit to the first message with his unverified device - cy.get("@bobSecondDevice").then((bobSecondDevice) => { - cy.get("@testEvent").then((testEvent) => { - bobSecondDevice.sendMessage(roomId, { - "m.new_content": { - msgtype: "m.text", - body: "Haa!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - }); - }); - - // the edit should have a warning - cy.contains(".mx_EventTile_body", "Haa!") - .closest(".mx_EventTile") - .within(() => { - cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); - }); - - // a second edit from the verified device should be ok - cy.get("@testEvent").then((testEvent) => { - this.bob.sendMessage(roomId, { - "m.new_content": { - msgtype: "m.text", - body: "Hee!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - }); - - cy.get(".mx_EventTile_last .mx_EventTile_body") - .within(() => { - cy.findByText("Hee!"); - }) - .closest(".mx_EventTile") - .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); - }); - }); - }); -}); diff --git a/cypress/e2e/crypto/utils.ts b/cypress/e2e/crypto/utils.ts deleted file mode 100644 index d0264ec99c9..00000000000 --- a/cypress/e2e/crypto/utils.ts +++ /dev/null @@ -1,243 +0,0 @@ -/* -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 type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; - -export type EmojiMapping = [emoji: string, name: string]; - -/** - * wait for the given client to receive an incoming verification request, and automatically accept it - * - * @param cli - matrix client we expect to receive a request - */ -export function waitForVerificationRequest(cli: MatrixClient): Promise { - return new Promise((resolve) => { - const onVerificationRequestEvent = async (request: VerificationRequest) => { - await request.accept(); - resolve(request); - }; - // @ts-ignore CryptoEvent is not exported to window.matrixcs; using the string value here - cli.once("crypto.verificationRequestReceived", onVerificationRequestEvent); - }); -} - -/** - * Automatically handle a SAS verification - * - * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they - * match, and return them - * - * @param verifier - verifier - * @returns A promise that resolves, with the emoji list, once we confirm the emojis - */ -export function handleSasVerification(verifier: Verifier): Promise { - return new Promise((resolve) => { - const onShowSas = (event: ISasEvent) => { - // @ts-ignore VerifierEvent is a pain to get at here as we don't have a reference to matrixcs; - // using the string value here - verifier.off("show_sas", onShowSas); - event.confirm(); - resolve(event.sas.emoji); - }; - - // @ts-ignore as above, avoiding reference to VerifierEvent - verifier.on("show_sas", onShowSas); - }); -} - -/** - * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. - */ -export function checkDeviceIsCrossSigned(): void { - let userId: string; - let myDeviceId: string; - cy.window({ log: false }) - .then((win) => { - // Get the userId and deviceId of the current user - const cli = win.mxMatrixClientPeg.get(); - const accessToken = cli.getAccessToken()!; - const homeserverUrl = cli.getHomeserverUrl(); - myDeviceId = cli.getDeviceId(); - userId = cli.getUserId(); - return cy.request({ - method: "POST", - url: `${homeserverUrl}/_matrix/client/v3/keys/query`, - headers: { Authorization: `Bearer ${accessToken}` }, - body: { device_keys: { [userId]: [] } }, - }); - }) - .then((res) => { - // there should be three cross-signing keys - expect(res.body.master_keys[userId]).to.have.property("keys"); - expect(res.body.self_signing_keys[userId]).to.have.property("keys"); - expect(res.body.user_signing_keys[userId]).to.have.property("keys"); - - // and the device should be signed by the self-signing key - const selfSigningKeyId = Object.keys(res.body.self_signing_keys[userId].keys)[0]; - - expect(res.body.device_keys[userId][myDeviceId]).to.exist; - - const myDeviceSignatures = res.body.device_keys[userId][myDeviceId].signatures[userId]; - expect(myDeviceSignatures[selfSigningKeyId]).to.exist; - }); -} - -/** - * Check that the current device is connected to the key backup. - */ -export function checkDeviceIsConnectedKeyBackup() { - cy.findByRole("button", { name: "User menu" }).click(); - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Security & Privacy" }).click(); - }); - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Restore from Backup" }).should("exist"); - }); -} - -/** - * Fill in the login form in element with the given creds. - * - * If a `securityKey` is given, verifies the new device using the key. - */ -export function logIntoElement(homeserverUrl: string, username: string, password: string, securityKey?: string) { - cy.visit("/#/login"); - - // select homeserver - cy.findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Other homeserver" }).type(homeserverUrl); - cy.findByRole("button", { name: "Continue" }).click(); - - // wait for the dialog to go away - cy.get(".mx_ServerPickerDialog").should("not.exist"); - - cy.findByRole("textbox", { name: "Username" }).type(username); - cy.findByPlaceholderText("Password").type(password); - cy.findByRole("button", { name: "Sign in" }).click(); - - // if a securityKey was given, verify the new device - if (securityKey !== undefined) { - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key" }).click(); - }); - cy.get(".mx_Dialog").within(() => { - // Fill in the security key - cy.get('input[type="password"]').type(securityKey); - }); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - cy.findByRole("button", { name: "Done" }).click(); - } -} - -/** - * Queue up Cypress commands to log out of Element - */ -export function logOutOfElement() { - cy.findByRole("button", { name: "User menu" }).click(); - cy.get(".mx_UserMenu_contextMenu").within(() => { - cy.findByRole("menuitem", { name: "Sign out" }).click(); - }); - cy.get(".mx_Dialog .mx_QuestionDialog").within(() => { - cy.findByRole("button", { name: "Sign out" }).click(); - }); - - // Wait for the login page to load - cy.findByRole("heading", { name: "Sign in" }).click(); -} - -/** - * Given a SAS verifier for a bot client, add cypress commands to: - * - wait for the bot to receive the emojis - * - check that the bot sees the same emoji as the application - * - * @param botVerificationRequest - a verification request in a bot client - */ -export function doTwoWaySasVerification(verifier: Verifier): void { - // on the bot side, wait for the emojis, confirm they match, and return them - const emojiPromise = handleSasVerification(verifier); - - // then, check that our application shows an emoji panel with the same emojis. - cy.wrap(emojiPromise).then((emojis: EmojiMapping[]) => { - cy.get(".mx_VerificationShowSas_emojiSas_block").then((emojiBlocks) => { - emojis.forEach((emoji: EmojiMapping, index: number) => { - // VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before - // displaying them. Once we drop support for legacy crypto, that code can go away, and so can the - // case-munging here. - expect(emojiBlocks[index].textContent.toLowerCase()).to.eq(emoji[0] + emoji[1].toLowerCase()); - }); - }); - }); -} - -/** - * Queue up cypress commands to open the security settings and enable secure key backup. - * - * Assumes that the current device has been cross-signed (which means that we skip a step where we set it up). - * - * Stores the security key in `@securityKey`. - */ -export function enableKeyBackup() { - cy.openUserSettings("Security & Privacy"); - cy.findByRole("button", { name: "Set up Secure Backup" }).click(); - cy.get(".mx_Dialog").within(() => { - // Recovery key is selected by default - cy.findByRole("button", { name: "Continue", timeout: 60000 }).click(); - - // copy the text ourselves - cy.get(".mx_CreateSecretStorageDialog_recoveryKey code").invoke("text").as("securityKey", { type: "static" }); - downloadKey(); - - cy.findByText("Secure Backup successful").should("exist"); - cy.findByRole("button", { name: "Done" }).click(); - cy.findByText("Secure Backup successful").should("not.exist"); - }); -} - -/** - * Queue up cypress commands to click on download button and continue - */ -export function downloadKey() { - // Clicking download instead of Copy because of https://github.com/cypress-io/cypress/issues/2851 - cy.findByRole("button", { name: "Download" }).click(); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); -} - -/** - * Create a shared, unencrypted room with the given user, and wait for them to join - * - * @param other - UserID of the other user - * @param opts - other options for the createRoom call - * - * @returns a cypress chainable which will yield the room ID - */ -export function createSharedRoomWithUser( - other: string, - opts: Omit = { name: "TestRoom" }, -): Cypress.Chainable { - return cy.createRoom({ ...opts, invite: [other] }).then((roomId) => { - cy.log(`Created test room ${roomId}`); - cy.viewRoomById(roomId); - - // wait for the other user to join the room, otherwise our attempt to open his user details may race - // with his join. - cy.findByText(" joined the room", { exact: false }).should("exist"); - - // Cypress complains if we return an immediate here rather than a promise. - return Promise.resolve(roomId); - }); -} diff --git a/cypress/e2e/crypto/verification.spec.ts b/cypress/e2e/crypto/verification.spec.ts deleted file mode 100644 index 31ee851532b..00000000000 --- a/cypress/e2e/crypto/verification.spec.ts +++ /dev/null @@ -1,429 +0,0 @@ -/* -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 { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; -import { CypressBot } from "../../support/bot"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { emitPromise } from "../../support/util"; -import { - checkDeviceIsConnectedKeyBackup, - checkDeviceIsCrossSigned, - doTwoWaySasVerification, - logIntoElement, - waitForVerificationRequest, -} from "./utils"; -import { getToast } from "../../support/toasts"; -import { UserCredentials } from "../../support/login"; - -/** Render a data URL and return the rendered image data */ -async function renderQRCode(dataUrl: string): Promise { - // create a new image and set the source to the data url - const img = new Image(); - await new Promise((r) => { - img.onload = r; - img.src = dataUrl; - }); - - // draw the image on a canvas - const myCanvas = new OffscreenCanvas(256, 256); - const ctx = myCanvas.getContext("2d"); - ctx.drawImage(img, 0, 0); - - // read the image data - return ctx.getImageData(0, 0, myCanvas.width, myCanvas.height); -} - -describe("Device verification", () => { - let aliceBotClient: CypressBot; - let homeserver: HomeserverInstance; - - beforeEach(() => { - cy.startHomeserver("default").then((data: HomeserverInstance) => { - homeserver = data; - - // Visit the login page of the app, to load the matrix sdk - cy.visit("/#/login"); - - // wait for the page to load - cy.window({ log: false }).should("have.property", "matrixcs"); - - // Create a new device for alice - cy.getBot(homeserver, { - rustCrypto: true, - bootstrapCrossSigning: true, - bootstrapSecretStorage: true, - }).then((bot) => { - aliceBotClient = bot; - }); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - /* Click the "Verify with another device" button, and have the bot client auto-accept it. - * - * Stores the incoming `VerificationRequest` on the bot client as `@verificationRequest`. - */ - function initiateAliceVerificationRequest() { - // alice bot waits for verification request - const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); - - // Click on "Verify with another device" - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with another device" }).click(); - }); - - // alice bot responds yes to verification request from alice - cy.wrap(promiseVerificationRequest).as("verificationRequest"); - } - - it("Verify device with SAS during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Launch the verification request between alice and the bot - initiateAliceVerificationRequest(); - - // Handle emoji SAS verification - cy.get(".mx_InfoDialog").within(() => { - cy.get("@verificationRequest").then(async (request: VerificationRequest) => { - // the bot chooses to do an emoji verification - const verifier = await request.startVerification("m.sas.v1"); - - // Handle emoji request and check that emojis are matching - doTwoWaySasVerification(verifier); - }); - - cy.findByRole("button", { name: "They match" }).click(); - cy.findByRole("button", { name: "Got it" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with QR code during login", () => { - // A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key" - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Launch the verification request between alice and the bot - initiateAliceVerificationRequest(); - - cy.get(".mx_InfoDialog").within(() => { - cy.get('[alt="QR Code"]').then((qrCode) => { - /* the bot scans the QR code */ - cy.get("@verificationRequest") - .then(async (request: VerificationRequest) => { - // feed the QR code into the verification request. - const qrData = await readQrCode(qrCode); - return await request.scanQRCode(qrData); - }) - .as("verifier"); - }); - - // Confirm that the bot user scanned successfully - cy.findByText("Almost there! Is your other device showing the same shield?"); - cy.findByRole("button", { name: "Yes" }).click(); - - cy.findByRole("button", { name: "Got it" }).click(); - }); - - // wait for the bot to see we have finished - cy.get("@verifier").then(async (verifier) => { - await verifier.verify(); - }); - - // the bot uploads the signatures asynchronously, so wait for that to happen - cy.wait(1000); - - // our device should trust the bot device - cy.getClient().then(async (cli) => { - const deviceStatus = await cli - .getCrypto()! - .getDeviceVerificationStatus(aliceBotClient.getUserId(), aliceBotClient.getDeviceId()); - if (!deviceStatus.isVerified()) { - throw new Error("Bot device was not verified after QR code verification"); - } - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with Security Phrase during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Select the security phrase - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key or Phrase" }).click(); - }); - - // Fill the passphrase - cy.get(".mx_Dialog").within(() => { - cy.get("input").type("new passphrase"); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - }); - - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Done" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Verify device with Security Key during login", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - // Select the security phrase - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Verify with Security Key or Phrase" }).click(); - }); - - // Fill the security key - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "use your Security Key" }).click(); - cy.get("#mx_securityKey").type(aliceBotClient.__cypress_recovery_key.encodedPrivateKey); - cy.contains(".mx_Dialog_primary:not([disabled])", "Continue").click(); - }); - - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Done" }).click(); - }); - - // Check that our device is now cross-signed - checkDeviceIsCrossSigned(); - - // Check that the current device is connected to key backup - checkDeviceIsConnectedKeyBackup(); - }); - - it("Handle incoming verification request with SAS", () => { - logIntoElement(homeserver.baseUrl, aliceBotClient.getUserId(), aliceBotClient.__cypress_password); - - /* Dismiss "Verify this device" */ - cy.get(".mx_AuthPage").within(() => { - cy.findByRole("button", { name: "Skip verification for now" }).click(); - cy.findByRole("button", { name: "I'll verify later" }).click(); - }); - - /* figure out the device id of the Element client */ - let elementDeviceId: string; - cy.window({ log: false }).then((win) => { - const cli = win.mxMatrixClientPeg.safeGet(); - elementDeviceId = cli.getDeviceId(); - expect(elementDeviceId).to.exist; - cy.log(`Got element device id: ${elementDeviceId}`); - }); - - /* Now initiate a verification request from the *bot* device. */ - let botVerificationRequest: VerificationRequest; - cy.then(() => { - async function initVerification() { - botVerificationRequest = await aliceBotClient - .getCrypto()! - .requestDeviceVerification(aliceBotClient.getUserId(), elementDeviceId); - } - - cy.wrap(initVerification(), { log: false }); - }).then(() => { - cy.log("Initiated verification request"); - }); - - /* Check the toast for the incoming request */ - getToast("Verification requested").within(() => { - // it should contain the device ID of the requesting device - cy.contains(`${aliceBotClient.getDeviceId()} from `); - - // Accept - cy.findByRole("button", { name: "Verify Session" }).click(); - }); - - /* Click 'Start' to start SAS verification */ - cy.findByRole("button", { name: "Start" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - cy.then(() => cy.wrap(awaitVerifier(botVerificationRequest))).then((verifier: Verifier) => { - // ... confirm ... - botVerificationRequest.verifier.verify(); - - // ... and then check the emoji match - doTwoWaySasVerification(verifier); - }); - - /* And we're all done! */ - cy.get(".mx_InfoDialog").within(() => { - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText(`You've successfully verified (${aliceBotClient.getDeviceId()})!`).should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); - }); -}); - -describe("User verification", () => { - // note that there are other tests that check user verification works in `crypto.spec.ts`. - - let aliceCredentials: UserCredentials; - let homeserver: HomeserverInstance; - let bob: CypressBot; - - beforeEach(() => { - cy.startHomeserver("default") - .as("homeserver") - .then((data) => { - homeserver = data; - cy.initTestUser(homeserver, "Alice", undefined, "alice_").then((credentials) => { - aliceCredentials = credentials; - }); - return cy.getBot(homeserver, { - displayName: "Bob", - autoAcceptInvites: true, - userIdPrefix: "bob_", - }); - }) - .then((data) => { - bob = data; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("can receive a verification request when there is no existing DM", () => { - cy.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - let dmRoomId: string; - let bobVerificationRequest: VerificationRequest; - cy.wrap(0).then(async () => { - dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - }); - - // accept the DM - cy.viewRoomByName("Bob"); - cy.findByRole("button", { name: "Start chatting" }).click(); - - // once Alice has joined, Bob starts the verification - cy.wrap(0).then(async () => { - const room = bob.getRoom(dmRoomId)!; - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - // @ts-ignore can't access the enum here - room.once("RoomState.members", resolve); - }); - } - bobVerificationRequest = await bob.getCrypto()!.requestVerificationDM(aliceCredentials.userId, dmRoomId); - }); - - // there should also be a toast - getToast("Verification requested").within(() => { - // it should contain the details of the requesting user - cy.contains(`Bob (${bob.credentials.userId})`); - - // Accept - cy.findByRole("button", { name: "Verify Session" }).click(); - }); - - // request verification by emoji - cy.get("#mx_RightPanel").findByRole("button", { name: "Verify by emoji" }).click(); - - cy.wrap(0) - .then(async () => { - /* on the bot side, wait for the verifier to exist ... */ - const verifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - verifier.verify(); - return verifier; - }) - .then((botVerifier) => { - // ... and then check the emoji match - doTwoWaySasVerification(botVerifier); - }); - - cy.findByRole("button", { name: "They match" }).click(); - cy.findByText("You've successfully verified Bob!").should("exist"); - cy.findByRole("button", { name: "Got it" }).click(); - }); -}); - -/** Extract the qrcode out of an on-screen html element */ -async function readQrCode(qrCode: JQuery) { - // because I don't know how to scrape the imagedata from the cypress browser window, - // we extract the data url and render it to a new canvas. - const imageData = await renderQRCode(qrCode.attr("src")); - - // now we can decode the QR code. - const result = jsQR(imageData.data, imageData.width, imageData.height); - return new Uint8Array(result.binaryData); -} - -async function createDMRoom(client: MatrixClient, userId: string): Promise { - const r = await client.createRoom({ - // @ts-ignore can't access the enum here - preset: "trusted_private_chat", - // @ts-ignore can't access the enum here - visibility: "private", - invite: [userId], - is_direct: true, - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); - - const roomId = r.room_id; - - // wait for the room to come down /sync - while (!client.getRoom(roomId)) { - await new Promise((resolve) => { - //@ts-ignore can't access the enum here - client.once("Room", resolve); - }); - } - - return roomId; -} - -/** - * Wait for a verifier to exist for a VerificationRequest - * - * @param botVerificationRequest - */ -async function awaitVerifier(botVerificationRequest: VerificationRequest): Promise { - while (!botVerificationRequest.verifier) { - await emitPromise(botVerificationRequest, "change"); - } - return botVerificationRequest.verifier; -} diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 4fc1a24e05c..b6d9713dd77 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -27,7 +27,6 @@ import type { ISendEventResponse, } from "matrix-js-sdk/src/matrix"; import Chainable = Cypress.Chainable; -import { UserCredentials } from "./login"; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -122,10 +121,6 @@ declare global { * @return the list of DMs with that user */ getDmRooms(userId: string): Chainable; - /** - * Boostraps cross-signing. - */ - bootstrapCrossSigning(credendtials: UserCredentials): Chainable; /** * Joins the given room by alias or ID * @param roomIdOrAlias the id or alias of the room to join @@ -218,23 +213,6 @@ Cypress.Commands.add("setAvatarUrl", (url: string): Chainable<{}> => { }); }); -Cypress.Commands.add("bootstrapCrossSigning", (credentials: UserCredentials) => { - cy.window({ log: false }).then((win) => { - win.mxMatrixClientPeg.matrixClient.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: credentials.userId, - }, - password: credentials.password, - }); - }, - }); - }); -}); - Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable => { return cy.getClient().then((cli) => cli.joinRoom(roomIdOrAlias)); }); diff --git a/playwright.config.ts b/playwright.config.ts index 4913d63e0f9..7ab3093ba63 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ video: "retain-on-failure", baseURL, permissions: ["clipboard-write", "clipboard-read"], + trace: "on-first-retry", }, webServer: { command: process.env.CI ? "npx serve -p 8080 -L ../webapp" : "yarn --cwd ../element-web start", diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts new file mode 100644 index 00000000000..83d1383675f --- /dev/null +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -0,0 +1,487 @@ +/* +Copyright 2022 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 type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { + createSharedRoomWithUser, + doTwoWaySasVerification, + copyAndContinue, + enableKeyBackup, + logIntoElement, + logOutOfElement, + waitForVerificationRequest, +} from "./utils"; +import { Bot } from "../../pages/bot"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { Client } from "../../pages/client"; + +const openRoomInfo = async (page: Page) => { + await page.getByRole("button", { name: "Room info" }).click(); + return page.locator(".mx_RightPanel"); +}; + +const checkDMRoom = async (page: Page) => { + const body = page.locator(".mx_RoomView_body"); + await expect(body.getByText("Alice created this DM.")).toBeVisible(); + await expect(body.getByText("Alice invited Bob")).toBeVisible({ timeout: 1000 }); + await expect(body.locator(".mx_cryptoEvent").getByText("Encryption enabled")).toBeVisible(); +}; + +const startDMWithBob = async (page: Page, bob: Bot) => { + await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click(); + await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId); + await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click(); + await expect( + page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"), + ).toBeVisible(); + await page.getByRole("button", { name: "Go" }).click(); +}; + +const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => { + // check the invite message + await expect( + page.locator(".mx_EventTile", { hasText: "Hey!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + + // Bob sends a response + await bob.sendMessage(bobRoomId, "Hoo!"); + await expect( + page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); +}; + +const bobJoin = async (page: Page, bob: Bot) => { + await bob.evaluate(async (cli) => { + const bobRooms = cli.getRooms(); + if (!bobRooms.length) { + await new Promise((resolve) => { + const onMembership = (_event) => { + cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership); + resolve(); + }; + cli.on(window.matrixcs.RoomMemberEvent.Membership, onMembership); + }); + } + }); + const roomId = await bob.joinRoomByName("Alice"); + + await expect(page.getByText("Bob joined the room")).toBeVisible(); + return roomId; +}; + +/** configure the given MatrixClient to auto-accept any invites */ +async function autoJoin(client: Client) { + await client.evaluate((cli) => { + cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + }); +} + +const verify = async (page: Page, bob: Bot) => { + const bobsVerificationRequestPromise = waitForVerificationRequest(bob); + + const roomInfo = await openRoomInfo(page); + await roomInfo.getByRole("menuitem", { name: "People" }).click(); + await roomInfo.getByText("Bob").click(); + await roomInfo.getByRole("button", { name: "Verify" }).click(); + await roomInfo.getByRole("button", { name: "Start Verification" }).click(); + + // this requires creating a DM, so can take a while. Give it a longer timeout. + await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); + + const request = await bobsVerificationRequestPromise; + // the bot user races with the Element user to hit the "verify by emoji" button + const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); + await doTwoWaySasVerification(page, verifier); + await roomInfo.getByRole("button", { name: "They match" }).click(); + await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); + await roomInfo.getByRole("button", { name: "Got it" }).click(); +}; + +test.describe("Cryptography", function () { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + autoAcceptInvites: false, + // XXX: We use a custom prefix here to coerce the Rust Crypto SDK to prefer `@user` in race resolution + // by using a prefix that is lexically after `@user` in the alphabet. + userIdPrefix: "zzz_", + }, + }); + + for (const isDeviceVerified of [true, false]) { + test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => { + /** + * Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server + * @param keyType + */ + async function verifyKey(app: ElementAppPage, keyType: string) { + const accountData: { encrypted: Record> } = await app.client.evaluate( + (cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`), + keyType, + ); + expect(accountData.encrypted).toBeDefined(); + const keys = Object.keys(accountData.encrypted); + const key = accountData.encrypted[keys[0]]; + expect(key.ciphertext).toBeDefined(); + expect(key.iv).toBeDefined(); + expect(key.mac).toBeDefined(); + } + + test("by recovery code", async ({ page, app, user: aliceCredentials }) => { + // Verified the device + if (isDeviceVerified) { + await app.client.bootstrapCrossSigning(aliceCredentials); + } + + await app.settings.openUserSettings("Security & Privacy"); + await page.getByRole("button", { name: "Set up Secure Backup" }).click(); + + const dialog = page.locator(".mx_Dialog"); + // Recovery key is selected by default + await dialog.getByRole("button", { name: "Continue" }).click(); + await copyAndContinue(page); + + // When the device is verified, the `Setting up keys` step is skipped + if (!isDeviceVerified) { + const uiaDialogTitle = page.locator(".mx_InteractiveAuthDialog .mx_Dialog_title"); + await expect(uiaDialogTitle.getByText("Setting up keys")).toBeVisible(); + await expect(uiaDialogTitle.getByText("Setting up keys")).not.toBeVisible(); + } + + await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); + await dialog.getByRole("button", { name: "Done" }).click(); + await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + + // Verify that the SSSS keys are in the account data stored in the server + await verifyKey(app, "master"); + await verifyKey(app, "self_signing"); + await verifyKey(app, "user_signing"); + }); + + test("by passphrase", async ({ page, app, user: aliceCredentials }) => { + // Verified the device + if (isDeviceVerified) { + await app.client.bootstrapCrossSigning(aliceCredentials); + } + + await app.settings.openUserSettings("Security & Privacy"); + await page.getByRole("button", { name: "Set up Secure Backup" }).click(); + + const dialog = page.locator(".mx_Dialog"); + // Select passphrase option + await dialog.getByText("Enter a Security Phrase").click(); + await dialog.getByRole("button", { name: "Continue" }).click(); + + // Fill passphrase input + await dialog.locator("input").fill("new passphrase for setting up a secure key backup"); + await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + // Confirm passphrase + await dialog.locator("input").fill("new passphrase for setting up a secure key backup"); + await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click(); + + await copyAndContinue(page); + + await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); + await dialog.getByRole("button", { name: "Done" }).click(); + await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + + // Verify that the SSSS keys are in the account data stored in the server + await verifyKey(app, "master"); + await verifyKey(app, "self_signing"); + await verifyKey(app, "user_signing"); + }); + }); + } + + test("creating a DM should work, being e2e-encrypted / user verification", async ({ + page, + app, + bot: bob, + user: aliceCredentials, + }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await startDMWithBob(page, bob); + // send first message + await page.getByRole("textbox", { name: "Send a messageā€¦" }).fill("Hey!"); + await page.getByRole("textbox", { name: "Send a messageā€¦" }).press("Enter"); + await checkDMRoom(page); + const bobRoomId = await bobJoin(page, bob); + await testMessages(page, bob, bobRoomId); + await verify(page, bob); + + // Assert that verified icon is rendered + await page.getByRole("button", { name: "Room members" }).click(); + await page.getByRole("button", { name: "Room information" }).click(); + await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted"); + + // Take a snapshot of RoomSummaryCard with a verified E2EE icon + await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png"); + }); + + test("should allow verification when there is no existing DM", async ({ + page, + app, + bot: bob, + user: aliceCredentials, + }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await autoJoin(bob); + + // we need to have a room with the other user present, so we can open the verification panel + await createSharedRoomWithUser(app, bob.credentials.userId); + await verify(page, bob); + }); + + test.describe("event shields", () => { + let testRoomId: string; + + test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await autoJoin(bob); + + // create an encrypted room + testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { + name: "TestRoom", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + }); + + test("should show the correct shield on e2e events", async ({ + page, + app, + bot: bob, + homeserver, + cryptoBackend, + }) => { + // Bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }); + + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("Unable to decrypt message"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); + await expect(lastE2eIcon).toHaveAttribute("aria-label", "This message could not be decrypted"); + + /* Should show a red padlock for an unencrypted message in an e2e room */ + await bob.evaluate( + (cli, testRoomId) => + cli.http.authedRequest( + window.matrixcs.Method.Put, + `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, + undefined, + { + msgtype: "m.text", + body: "test unencrypted", + }, + ), + testRoomId, + ); + + await expect(last).toContainText("test unencrypted"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await expect(lastE2eIcon).toHaveAttribute("aria-label", "Not encrypted"); + + /* Should show no padlock for an unverified user */ + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + // the message should appear, decrypted, with no warning, but also no "verified" + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* Now verify Bob */ + await verify(page, bob); + + /* Existing message should be updated when user is verified. */ + await expect(last).toContainText("test encrypted 1"); + // still no e2e icon + await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); + + /* should show no padlock, and be verified, for a message from a verified device */ + await bob.sendMessage(testRoomId, "test encrypted 2"); + + await expect(lastTile).toContainText("test encrypted 2"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* should show red padlock for a message from an unverified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); + await expect(lastTile).toContainText("test encrypted from unverified"); + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await expect(lastTileE2eIcon).toHaveAttribute( + "aria-label", + "Encrypted by a device not verified by its owner.", + ); + + /* Should show a grey padlock for a message from an unknown device */ + // bob deletes his second device + await bobSecondDevice.evaluate((cli) => cli.logout(true)); + + // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. + async function awaitOneDevice(iterations = 1) { + const rightPanel = page.locator(".mx_RightPanel"); + await rightPanel.getByRole("button", { name: "Room members" }).click(); + await rightPanel.getByText("Bob").click(); + const sessionCountText = await rightPanel + .locator(".mx_UserInfo_devices") + .getByText(" session", { exact: false }) + .textContent(); + // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here + if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { + if (iterations >= 10) { + throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); + } + await awaitOneDevice(iterations + 1); + } + } + + await awaitOneDevice(); + + // close and reopen the room, to get the shield to update. + await app.viewRoomByName("Bob"); + await app.viewRoomByName("TestRoom"); + + // some debate over whether this should have a red or a grey shield. Legacy crypto shows a grey shield, + // Rust crypto a red one. + await expect(last).toContainText("test encrypted from unverified"); + if (cryptoBackend === "rust") { + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + } else { + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/); + } + await expect(lastE2eIcon).toHaveAttribute("aria-label", "Encrypted by an unknown or deleted device."); + }); + + // XXX: Failed since migration to Playwright + test.skip("Should show a grey padlock for a key restored from backup", async ({ + page, + app, + bot: bob, + homeserver, + user: aliceCredentials, + }) => { + const securityKey = await enableKeyBackup(app); + + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + // It can take up to 10 seconds for the key to be backed up. We don't really have much option other than + // to wait :/ + await page.waitForTimeout(10000); + + /* log out, and back in */ + await logOutOfElement(page); + await logIntoElement(page, homeserver, aliceCredentials, securityKey); + + /* go back to the test room and find Bob's message again */ + await app.viewRoomById(testRoomId); + await expect(lastTile).toContainText("test encrypted 1"); + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await expect(lastTileE2eIcon).toHaveAttribute("aria-label", "Encrypted by an unknown or deleted device."); + }); + + test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { + // bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + // verify Bob + await verify(page, bob); + + // bob sends a valid event + const testEvent = await bob.sendMessage(testRoomId, "Hoo!"); + + // the message should appear, decrypted, with no warning + await expect( + page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + + // bob sends an edit to the first message with his unverified device + await bobSecondDevice.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + // the edit should have a warning + await expect( + page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).toBeVisible(); + + // a second edit from the verified device should be ok + await bob.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Hee!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + await expect( + page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + }); + }); +}); diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index c0120f39577..070e615e874 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -14,9 +14,102 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Page, expect } from "@playwright/test"; +import { type Page, expect, JSHandle } from "@playwright/test"; +import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { + VerificationRequest, + Verifier, + EmojiMapping, + VerifierEvent, +} from "matrix-js-sdk/src/crypto-api/verification"; +import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; +import { Client } from "../../pages/client"; +import { ElementAppPage } from "../../pages/ElementAppPage"; + +/** + * wait for the given client to receive an incoming verification request, and automatically accept it + * + * @param client - matrix client handle we expect to receive a request + */ +export async function waitForVerificationRequest(client: Client): Promise> { + return client.evaluateHandle((cli) => { + return new Promise((resolve) => { + console.log("~~"); + const onVerificationRequestEvent = async (request: VerificationRequest) => { + console.log("@@", request); + await request.accept(); + resolve(request); + }; + cli.once( + "crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived, + onVerificationRequestEvent, + ); + }); + }); +} + +/** + * Automatically handle a SAS verification + * + * Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they + * match, and return them + * + * @param verifier - verifier + * @returns A promise that resolves, with the emoji list, once we confirm the emojis + */ +export function handleSasVerification(verifier: JSHandle): Promise { + return verifier.evaluate((verifier) => { + const event = verifier.getShowSasCallbacks(); + if (event) return event.sas.emoji; + + return new Promise((resolve) => { + const onShowSas = (event: ISasEvent) => { + verifier.off("show_sas" as VerifierEvent, onShowSas); + event.confirm(); + resolve(event.sas.emoji); + }; + + verifier.on("show_sas" as VerifierEvent, onShowSas); + }); + }); +} + +/** + * Check that the user has published cross-signing keys, and that the user's device has been cross-signed. + */ +export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise { + const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => { + const deviceId = cli.getDeviceId(); + const userId = cli.getUserId(); + const keys = await cli.downloadKeysForUsers([userId]); + + return { userId, deviceId, keys }; + }); + + // there should be three cross-signing keys + expect(keys.master_keys[userId]).toHaveProperty("keys"); + expect(keys.self_signing_keys[userId]).toHaveProperty("keys"); + expect(keys.user_signing_keys[userId]).toHaveProperty("keys"); + + // and the device should be signed by the self-signing key + const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0]; + + expect(keys.device_keys[userId][deviceId]).toBeDefined(); + + const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId]; + expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined(); +} + +/** + * Check that the current device is connected to the key backup. + */ +export async function checkDeviceIsConnectedKeyBackup(page: Page) { + await page.getByRole("button", { name: "User menu" }).click(); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click(); + await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible(); +} /** * Fill in the login form in element with the given creds. @@ -52,3 +145,95 @@ export async function logIntoElement( await page.getByRole("button", { name: "Done" }).click(); } } + +export async function logOutOfElement(page: Page) { + await page.getByRole("button", { name: "User menu" }).click(); + await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click(); + await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click(); + + // Wait for the login page to load + await page.getByRole("heading", { name: "Sign in" }).click(); +} + +/** + * Given a SAS verifier for a bot client: + * - wait for the bot to receive the emojis + * - check that the bot sees the same emoji as the application + * + * @param verifier - a verifier in a bot client + */ +export async function doTwoWaySasVerification(page: Page, verifier: JSHandle): Promise { + // on the bot side, wait for the emojis, confirm they match, and return them + const emojis = await handleSasVerification(verifier); + + const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block"); + await expect(emojiBlocks).toHaveCount(emojis.length); + + // then, check that our application shows an emoji panel with the same emojis. + for (let i = 0; i < emojis.length; i++) { + const emoji = emojis[i]; + const emojiBlock = emojiBlocks.nth(i); + const textContent = await emojiBlock.textContent(); + // VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before + // displaying them. Once we drop support for legacy crypto, that code can go away, and so can the + // case-munging here. + expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase()); + } +} + +/** + * Open the security settings and enable secure key backup. + * + * Assumes that the current device has been cross-signed (which means that we skip a step where we set it up). + * + * Returns the security key + */ +export async function enableKeyBackup(app: ElementAppPage): Promise { + await app.settings.openUserSettings("Security & Privacy"); + await app.page.getByRole("button", { name: "Set up Secure Backup" }).click(); + const dialog = app.page.locator(".mx_Dialog"); + // Recovery key is selected by default + await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 }); + + // copy the text ourselves + const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent(); + await copyAndContinue(app.page); + + await expect(dialog.getByText("Secure Backup successful")).toBeVisible(); + await dialog.getByRole("button", { name: "Done" }).click(); + await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible(); + + return securityKey; +} + +/** + * Click on copy and continue buttons to dismiss the security key dialog + */ +export async function copyAndContinue(page: Page) { + await page.getByRole("button", { name: "Copy" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); +} + +/** + * Create a shared, unencrypted room with the given user, and wait for them to join + * + * @param other - UserID of the other user + * @param opts - other options for the createRoom call + * + * @returns a promise which resolves to the room ID + */ +export async function createSharedRoomWithUser( + app: ElementAppPage, + other: string, + opts: Omit = { name: "TestRoom" }, +): Promise { + const roomId = await app.client.createRoom({ ...opts, invite: [other] }); + + await app.viewRoomById(roomId); + + // wait for the other user to join the room, otherwise our attempt to open his user details may race + // with his join. + await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible(); + + return roomId; +} diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/verification.spec.ts new file mode 100644 index 00000000000..fc499f3f726 --- /dev/null +++ b/playwright/e2e/crypto/verification.spec.ts @@ -0,0 +1,348 @@ +/* +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; + + 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); + await aliceBotClient.prepareClient(); + + await page.waitForTimeout(20000); + }); + + // 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 + await checkDeviceIsConnectedKeyBackup(page); + }); + + 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 + await checkDeviceIsConnectedKeyBackup(page); + }); + + 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 + await checkDeviceIsConnectedKeyBackup(page); + }); + + 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 + await checkDeviceIsConnectedKeyBackup(page); + }); + + 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 { + 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): Promise> { + return botVerificationRequest.evaluateHandle(async (verificationRequest) => { + while (!verificationRequest.verifier) { + await new Promise((r) => verificationRequest.once("change" as any, r)); + } + return verificationRequest.verifier; + }); +} diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index ee78be2d062..95ab529bb7b 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -196,7 +196,7 @@ export const test = base.extend< }, botCreateOpts: {}, - bot: async ({ page, homeserver, botCreateOpts }, use) => { + bot: async ({ page, homeserver, botCreateOpts, user }, use) => { const bot = new Bot(page, homeserver, botCreateOpts); await bot.prepareClient(); // eagerly register the bot await use(bot); diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 8d5b43f1d82..742acc13f40 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -21,7 +21,7 @@ import { Client } from "./client"; import { Labs } from "./labs"; export class ElementAppPage { - public constructor(private readonly page: Page) {} + public constructor(public readonly page: Page) {} public labs = new Labs(this.page); public settings = new Settings(this.page); @@ -91,6 +91,10 @@ export class ElementAppPage { .click(); } + public async viewRoomById(roomId: string): Promise { + await this.page.goto(`/#/room/${roomId}`); + } + /** * Get the composer element * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index fd122680c43..2a6df36e82c 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -18,8 +18,10 @@ import { JSHandle, Page } from "@playwright/test"; import { uniqueId } from "lodash"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { Logger } from "matrix-js-sdk/src/logger"; import type { AddSecretStorageKeyOpts } from "matrix-js-sdk/src/secret-storage"; import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; +import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { Client } from "./client"; export interface CreateBotOpts { @@ -60,14 +62,27 @@ const defaultCreateBotOptions = { bootstrapCrossSigning: true, } satisfies CreateBotOpts; +type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; + export class Bot extends Client { public credentials?: Credentials; + private handlePromise: Promise>; constructor(page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) { super(page); this.opts = Object.assign({}, defaultCreateBotOptions, opts); } + public setCredentials(credentials: Credentials): void { + if (this.credentials) throw new Error("Bot has already started"); + this.credentials = credentials; + } + + public async getRecoveryKey(): Promise { + const client = await this.getClientHandle(); + return client.evaluate((cli) => cli.__playwright_recovery_key); + } + private async getCredentials(): Promise { if (this.credentials) return this.credentials; // We want to pad the uniqueId but not the prefix @@ -82,9 +97,36 @@ export class Bot extends Client { return this.credentials; } - protected async getClientHandle(): Promise> { - return this.page.evaluateHandle( + protected async getClientHandle(): Promise> { + if (this.handlePromise) return this.handlePromise; + + this.handlePromise = this.page.evaluateHandle( async ({ homeserver, credentials, opts }) => { + function getLogger(loggerName: string): Logger { + const logger = { + getChild: (namespace: string) => getLogger(`${loggerName}:${namespace}`), + trace(...msg: any[]): void { + console.trace(loggerName, ...msg); + }, + debug(...msg: any[]): void { + console.debug(loggerName, ...msg); + }, + info(...msg: any[]): void { + console.info(loggerName, ...msg); + }, + warn(...msg: any[]): void { + console.warn(loggerName, ...msg); + }, + error(...msg: any[]): void { + console.error(loggerName, ...msg); + }, + } satisfies Logger; + + return logger as unknown as Logger; + } + + const logger = getLogger(`cypress bot ${credentials.userId}`); + const keys = {}; const getCrossSigningKey = (type: string) => { @@ -123,7 +165,8 @@ export class Bot extends Client { scheduler: new window.matrixcs.MatrixScheduler(), cryptoStore: new window.matrixcs.MemoryCryptoStore(), cryptoCallbacks, - }); + logger, + }) as ExtendedMatrixClient; if (opts.autoAcceptInvites) { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { @@ -180,5 +223,6 @@ export class Bot extends Client { opts: this.opts, }, ); + return this.handlePromise; } } diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index c1e4f7a9ed8..1b893fc9706 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -27,6 +27,7 @@ import type { ReceiptType, IRoomDirectoryOptions, } from "matrix-js-sdk/src/matrix"; +import { Credentials } from "../plugins/homeserver"; export class Client { protected client: JSHandle; @@ -100,7 +101,14 @@ export class Client { * @param roomId ID of the room to send the message into * @param content the event content to send */ - public async sendMessage(roomId: string, content: IContent): Promise { + public async sendMessage(roomId: string, content: IContent | string): Promise { + if (typeof content === "string") { + content = { + body: content, + msgtype: "m.text", + }; + } + const client = await this.prepareClient(); return client.evaluate( (client, { roomId, content }) => { @@ -177,13 +185,14 @@ export class Client { * Make this bot join a room by name * @param roomName Name of the room to join */ - public async joinRoomByName(roomName: string): Promise { + public async joinRoomByName(roomName: string): Promise { const client = await this.prepareClient(); - await client.evaluate( - (client, { roomName }) => { + return client.evaluate( + async (client, { roomName }) => { const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName); if (room) { - return client.joinRoom(room.roomId); + await client.joinRoom(room.roomId); + return room.roomId; } throw new Error(`Bot room join failed. Cannot find room '${roomName}'`); }, @@ -227,8 +236,29 @@ export class Client { public async publicRooms(options?: IRoomDirectoryOptions): ReturnType { const client = await this.prepareClient(); - return await client.evaluate((client, options) => { + return client.evaluate((client, options) => { return client.publicRooms(options); }, options); } + + /** + * Boostraps cross-signing. + */ + public async bootstrapCrossSigning(credentials: Credentials): Promise { + const client = await this.prepareClient(); + return client.evaluate(async (client, credentials) => { + await client.getCrypto().bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (func) => { + await func({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + }, + }); + }, credentials); + } } diff --git a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..e5dae2a5d323c184955e2e3dd85373896b14fda6 GIT binary patch literal 27177 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