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