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 4680a4b086..0000000000 --- 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<Room>("@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<void>((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<string, Record<string, string>> }) => { - 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<ISendEventResponse>( - // @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<string>("@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<string>("@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<MatrixClient>("@bobSecondDevice").then((bobSecondDevice) => { - cy.get<ISendEventResponse>("@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<ISendEventResponse>("@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 d0264ec99c..0000000000 --- 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<VerificationRequest> { - return new Promise<VerificationRequest>((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<EmojiMapping[]> { - return new Promise<EmojiMapping[]>((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<ICreateRoomOpts, "invite"> = { name: "TestRoom" }, -): Cypress.Chainable<string> { - 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 31ee851532..0000000000 --- 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<ImageData> { - // 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>("@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>("@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>("@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<HTMLElement>) { - // 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<string> { - 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<Verifier> { - while (!botVerificationRequest.verifier) { - await emitPromise(botVerificationRequest, "change"); - } - return botVerificationRequest.verifier; -} diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 4fc1a24e05..b6d9713dd7 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<string[]>; - /** - * Boostraps cross-signing. - */ - bootstrapCrossSigning(credendtials: UserCredentials): Chainable<void>; /** * 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<Room> => { return cy.getClient().then((cli) => cli.joinRoom(roomIdOrAlias)); }); diff --git a/playwright.config.ts b/playwright.config.ts index 4913d63e0f..7ab3093ba6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,7 @@ export default defineConfig<TestOptions>({ 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 0000000000..83d1383675 --- /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<void>((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<string, Record<string, string>> } = 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 c0120f3957..070e615e87 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<JSHandle<VerificationRequest>> { + return client.evaluateHandle((cli) => { + return new Promise<VerificationRequest>((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<Verifier>): Promise<EmojiMapping[]> { + return verifier.evaluate((verifier) => { + const event = verifier.getShowSasCallbacks(); + if (event) return event.sas.emoji; + + return new Promise<EmojiMapping[]>((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<void> { + 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<Verifier>): Promise<void> { + // 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<string> { + 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<ICreateRoomOpts, "invite"> = { name: "TestRoom" }, +): Promise<string> { + 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 0000000000..fc499f3f72 --- /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<JSHandle<VerificationRequest>> { + // alice bot waits for verification request + const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient); + + // Click on "Verify with another device" + await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click(); + + // alice bot responds yes to verification request from alice + return promiseVerificationRequest; + } + + test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => { + await logIntoElement(page, homeserver, credentials); + + // Launch the verification request between alice and the bot + const verificationRequest = await initiateAliceVerificationRequest(page); + + // Handle emoji SAS verification + const infoDialog = page.locator(".mx_InfoDialog"); + // the bot chooses to do an emoji verification + const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1")); + + // Handle emoji request and check that emojis are matching + await doTwoWaySasVerification(page, verifier); + + await infoDialog.getByRole("button", { name: "They match" }).click(); + await infoDialog.getByRole("button", { name: "Got it" }).click(); + + // Check that our device is now cross-signed + await checkDeviceIsCrossSigned(app); + + // Check that the current device is connected to key backup + 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<string> { + return client.createRoom({ + preset: "trusted_private_chat" as Preset, + visibility: "private" as Visibility, + invite: [userId], + is_direct: true, + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); +} + +/** + * Wait for a verifier to exist for a VerificationRequest + * + * @param botVerificationRequest + */ +async function awaitVerifier(botVerificationRequest: JSHandle<VerificationRequest>): Promise<JSHandle<Verifier>> { + return botVerificationRequest.evaluateHandle(async (verificationRequest) => { + while (!verificationRequest.verifier) { + await new Promise((r) => verificationRequest.once("change" as any, r)); + } + return verificationRequest.verifier; + }); +} diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index ee78be2d06..95ab529bb7 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 8d5b43f1d8..742acc13f4 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<void> { + 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 fd122680c4..2a6df36e82 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<JSHandle<ExtendedMatrixClient>>; 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<GeneratedSecretStorageKey> { + const client = await this.getClientHandle(); + return client.evaluate((cli) => cli.__playwright_recovery_key); + } + private async getCredentials(): Promise<Credentials> { 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<JSHandle<MatrixClient>> { - return this.page.evaluateHandle( + protected async getClientHandle(): Promise<JSHandle<ExtendedMatrixClient>> { + 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 c1e4f7a9ed..1b893fc970 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<MatrixClient>; @@ -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<ISendEventResponse> { + public async sendMessage(roomId: string, content: IContent | string): Promise<ISendEventResponse> { + 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<void> { + public async joinRoomByName(roomName: string): Promise<string> { 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<MatrixClient["publicRooms"]> { 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<void> { + 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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 literal 0 HcmV?d00001