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