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>
This commit is contained in:
Michael Telatynski 2023-12-12 08:55:29 +00:00 committed by GitHub
parent 0f42418b5c
commit 5104d53ddf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1111 additions and 1259 deletions

View file

@ -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");
});
});
});
});

View file

@ -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);
});
}

View file

@ -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;
}

View file

@ -27,7 +27,6 @@ import type {
ISendEventResponse, ISendEventResponse,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import Chainable = Cypress.Chainable; import Chainable = Cypress.Chainable;
import { UserCredentials } from "./login";
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace // eslint-disable-next-line @typescript-eslint/no-namespace
@ -122,10 +121,6 @@ declare global {
* @return the list of DMs with that user * @return the list of DMs with that user
*/ */
getDmRooms(userId: string): Chainable<string[]>; getDmRooms(userId: string): Chainable<string[]>;
/**
* Boostraps cross-signing.
*/
bootstrapCrossSigning(credendtials: UserCredentials): Chainable<void>;
/** /**
* Joins the given room by alias or ID * Joins the given room by alias or ID
* @param roomIdOrAlias the id or alias of the room to join * @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> => { Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable<Room> => {
return cy.getClient().then((cli) => cli.joinRoom(roomIdOrAlias)); return cy.getClient().then((cli) => cli.joinRoom(roomIdOrAlias));
}); });

View file

@ -27,6 +27,7 @@ export default defineConfig<TestOptions>({
video: "retain-on-failure", video: "retain-on-failure",
baseURL, baseURL,
permissions: ["clipboard-write", "clipboard-read"], permissions: ["clipboard-write", "clipboard-read"],
trace: "on-first-retry",
}, },
webServer: { webServer: {
command: process.env.CI ? "npx serve -p 8080 -L ../webapp" : "yarn --cwd ../element-web start", command: process.env.CI ? "npx serve -p 8080 -L ../webapp" : "yarn --cwd ../element-web start",

View file

@ -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();
});
});
});

View file

@ -14,9 +14,102 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { 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. * 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(); 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;
}

View file

@ -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;
});
}

View file

@ -196,7 +196,7 @@ export const test = base.extend<
}, },
botCreateOpts: {}, botCreateOpts: {},
bot: async ({ page, homeserver, botCreateOpts }, use) => { bot: async ({ page, homeserver, botCreateOpts, user }, use) => {
const bot = new Bot(page, homeserver, botCreateOpts); const bot = new Bot(page, homeserver, botCreateOpts);
await bot.prepareClient(); // eagerly register the bot await bot.prepareClient(); // eagerly register the bot
await use(bot); await use(bot);

View file

@ -21,7 +21,7 @@ import { Client } from "./client";
import { Labs } from "./labs"; import { Labs } from "./labs";
export class ElementAppPage { export class ElementAppPage {
public constructor(private readonly page: Page) {} public constructor(public readonly page: Page) {}
public labs = new Labs(this.page); public labs = new Labs(this.page);
public settings = new Settings(this.page); public settings = new Settings(this.page);
@ -91,6 +91,10 @@ export class ElementAppPage {
.click(); .click();
} }
public async viewRoomById(roomId: string): Promise<void> {
await this.page.goto(`/#/room/${roomId}`);
}
/** /**
* Get the composer element * Get the composer element
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer

View file

@ -18,8 +18,10 @@ import { JSHandle, Page } from "@playwright/test";
import { uniqueId } from "lodash"; import { uniqueId } from "lodash";
import type { MatrixClient } from "matrix-js-sdk/src/matrix"; 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 { AddSecretStorageKeyOpts } from "matrix-js-sdk/src/secret-storage";
import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import { Client } from "./client"; import { Client } from "./client";
export interface CreateBotOpts { export interface CreateBotOpts {
@ -60,14 +62,27 @@ const defaultCreateBotOptions = {
bootstrapCrossSigning: true, bootstrapCrossSigning: true,
} satisfies CreateBotOpts; } satisfies CreateBotOpts;
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
export class Bot extends Client { export class Bot extends Client {
public credentials?: Credentials; public credentials?: Credentials;
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;
constructor(page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) { constructor(page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) {
super(page); super(page);
this.opts = Object.assign({}, defaultCreateBotOptions, opts); 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> { private async getCredentials(): Promise<Credentials> {
if (this.credentials) return this.credentials; if (this.credentials) return this.credentials;
// We want to pad the uniqueId but not the prefix // We want to pad the uniqueId but not the prefix
@ -82,9 +97,36 @@ export class Bot extends Client {
return this.credentials; return this.credentials;
} }
protected async getClientHandle(): Promise<JSHandle<MatrixClient>> { protected async getClientHandle(): Promise<JSHandle<ExtendedMatrixClient>> {
return this.page.evaluateHandle( if (this.handlePromise) return this.handlePromise;
this.handlePromise = this.page.evaluateHandle(
async ({ homeserver, credentials, opts }) => { 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 keys = {};
const getCrossSigningKey = (type: string) => { const getCrossSigningKey = (type: string) => {
@ -123,7 +165,8 @@ export class Bot extends Client {
scheduler: new window.matrixcs.MatrixScheduler(), scheduler: new window.matrixcs.MatrixScheduler(),
cryptoStore: new window.matrixcs.MemoryCryptoStore(), cryptoStore: new window.matrixcs.MemoryCryptoStore(),
cryptoCallbacks, cryptoCallbacks,
}); logger,
}) as ExtendedMatrixClient;
if (opts.autoAcceptInvites) { if (opts.autoAcceptInvites) {
cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
@ -180,5 +223,6 @@ export class Bot extends Client {
opts: this.opts, opts: this.opts,
}, },
); );
return this.handlePromise;
} }
} }

View file

@ -27,6 +27,7 @@ import type {
ReceiptType, ReceiptType,
IRoomDirectoryOptions, IRoomDirectoryOptions,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { Credentials } from "../plugins/homeserver";
export class Client { export class Client {
protected client: JSHandle<MatrixClient>; protected client: JSHandle<MatrixClient>;
@ -100,7 +101,14 @@ export class Client {
* @param roomId ID of the room to send the message into * @param roomId ID of the room to send the message into
* @param content the event content to send * @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(); const client = await this.prepareClient();
return client.evaluate( return client.evaluate(
(client, { roomId, content }) => { (client, { roomId, content }) => {
@ -177,13 +185,14 @@ export class Client {
* Make this bot join a room by name * Make this bot join a room by name
* @param roomName Name of the room to join * @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(); const client = await this.prepareClient();
await client.evaluate( return client.evaluate(
(client, { roomName }) => { async (client, { roomName }) => {
const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName); const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName);
if (room) { 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}'`); 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"]> { public async publicRooms(options?: IRoomDirectoryOptions): ReturnType<MatrixClient["publicRooms"]> {
const client = await this.prepareClient(); const client = await this.prepareClient();
return await client.evaluate((client, options) => { return client.evaluate((client, options) => {
return client.publicRooms(options); return client.publicRooms(options);
}, 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);
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB