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:
parent
0f42418b5c
commit
5104d53ddf
13 changed files with 1111 additions and 1259 deletions
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
|
|
|
@ -27,6 +27,7 @@ export default defineConfig<TestOptions>({
|
|||
video: "retain-on-failure",
|
||||
baseURL,
|
||||
permissions: ["clipboard-write", "clipboard-read"],
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: process.env.CI ? "npx serve -p 8080 -L ../webapp" : "yarn --cwd ../element-web start",
|
||||
|
|
487
playwright/e2e/crypto/crypto.spec.ts
Normal file
487
playwright/e2e/crypto/crypto.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -14,9 +14,102 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Page, expect } from "@playwright/test";
|
||||
import { type Page, expect, JSHandle } from "@playwright/test";
|
||||
|
||||
import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type {
|
||||
VerificationRequest,
|
||||
Verifier,
|
||||
EmojiMapping,
|
||||
VerifierEvent,
|
||||
} from "matrix-js-sdk/src/crypto-api/verification";
|
||||
import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS";
|
||||
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
|
||||
import { Client } from "../../pages/client";
|
||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||
|
||||
/**
|
||||
* wait for the given client to receive an incoming verification request, and automatically accept it
|
||||
*
|
||||
* @param client - matrix client handle we expect to receive a request
|
||||
*/
|
||||
export async function waitForVerificationRequest(client: Client): Promise<JSHandle<VerificationRequest>> {
|
||||
return client.evaluateHandle((cli) => {
|
||||
return new Promise<VerificationRequest>((resolve) => {
|
||||
console.log("~~");
|
||||
const onVerificationRequestEvent = async (request: VerificationRequest) => {
|
||||
console.log("@@", request);
|
||||
await request.accept();
|
||||
resolve(request);
|
||||
};
|
||||
cli.once(
|
||||
"crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived,
|
||||
onVerificationRequestEvent,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically handle a SAS verification
|
||||
*
|
||||
* Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they
|
||||
* match, and return them
|
||||
*
|
||||
* @param verifier - verifier
|
||||
* @returns A promise that resolves, with the emoji list, once we confirm the emojis
|
||||
*/
|
||||
export function handleSasVerification(verifier: JSHandle<Verifier>): Promise<EmojiMapping[]> {
|
||||
return verifier.evaluate((verifier) => {
|
||||
const event = verifier.getShowSasCallbacks();
|
||||
if (event) return event.sas.emoji;
|
||||
|
||||
return new Promise<EmojiMapping[]>((resolve) => {
|
||||
const onShowSas = (event: ISasEvent) => {
|
||||
verifier.off("show_sas" as VerifierEvent, onShowSas);
|
||||
event.confirm();
|
||||
resolve(event.sas.emoji);
|
||||
};
|
||||
|
||||
verifier.on("show_sas" as VerifierEvent, onShowSas);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the user has published cross-signing keys, and that the user's device has been cross-signed.
|
||||
*/
|
||||
export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<void> {
|
||||
const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => {
|
||||
const deviceId = cli.getDeviceId();
|
||||
const userId = cli.getUserId();
|
||||
const keys = await cli.downloadKeysForUsers([userId]);
|
||||
|
||||
return { userId, deviceId, keys };
|
||||
});
|
||||
|
||||
// there should be three cross-signing keys
|
||||
expect(keys.master_keys[userId]).toHaveProperty("keys");
|
||||
expect(keys.self_signing_keys[userId]).toHaveProperty("keys");
|
||||
expect(keys.user_signing_keys[userId]).toHaveProperty("keys");
|
||||
|
||||
// and the device should be signed by the self-signing key
|
||||
const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0];
|
||||
|
||||
expect(keys.device_keys[userId][deviceId]).toBeDefined();
|
||||
|
||||
const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId];
|
||||
expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the current device is connected to the key backup.
|
||||
*/
|
||||
export async function checkDeviceIsConnectedKeyBackup(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
|
||||
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the login form in element with the given creds.
|
||||
|
@ -52,3 +145,95 @@ export async function logIntoElement(
|
|||
await page.getByRole("button", { name: "Done" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logOutOfElement(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).click();
|
||||
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
|
||||
await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click();
|
||||
|
||||
// Wait for the login page to load
|
||||
await page.getByRole("heading", { name: "Sign in" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a SAS verifier for a bot client:
|
||||
* - wait for the bot to receive the emojis
|
||||
* - check that the bot sees the same emoji as the application
|
||||
*
|
||||
* @param verifier - a verifier in a bot client
|
||||
*/
|
||||
export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Verifier>): Promise<void> {
|
||||
// on the bot side, wait for the emojis, confirm they match, and return them
|
||||
const emojis = await handleSasVerification(verifier);
|
||||
|
||||
const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block");
|
||||
await expect(emojiBlocks).toHaveCount(emojis.length);
|
||||
|
||||
// then, check that our application shows an emoji panel with the same emojis.
|
||||
for (let i = 0; i < emojis.length; i++) {
|
||||
const emoji = emojis[i];
|
||||
const emojiBlock = emojiBlocks.nth(i);
|
||||
const textContent = await emojiBlock.textContent();
|
||||
// VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before
|
||||
// displaying them. Once we drop support for legacy crypto, that code can go away, and so can the
|
||||
// case-munging here.
|
||||
expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the security settings and enable secure key backup.
|
||||
*
|
||||
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
|
||||
*
|
||||
* Returns the security key
|
||||
*/
|
||||
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
|
||||
const dialog = app.page.locator(".mx_Dialog");
|
||||
// Recovery key is selected by default
|
||||
await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 });
|
||||
|
||||
// copy the text ourselves
|
||||
const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent();
|
||||
await copyAndContinue(app.page);
|
||||
|
||||
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Done" }).click();
|
||||
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
|
||||
|
||||
return securityKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on copy and continue buttons to dismiss the security key dialog
|
||||
*/
|
||||
export async function copyAndContinue(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared, unencrypted room with the given user, and wait for them to join
|
||||
*
|
||||
* @param other - UserID of the other user
|
||||
* @param opts - other options for the createRoom call
|
||||
*
|
||||
* @returns a promise which resolves to the room ID
|
||||
*/
|
||||
export async function createSharedRoomWithUser(
|
||||
app: ElementAppPage,
|
||||
other: string,
|
||||
opts: Omit<ICreateRoomOpts, "invite"> = { name: "TestRoom" },
|
||||
): Promise<string> {
|
||||
const roomId = await app.client.createRoom({ ...opts, invite: [other] });
|
||||
|
||||
await app.viewRoomById(roomId);
|
||||
|
||||
// wait for the other user to join the room, otherwise our attempt to open his user details may race
|
||||
// with his join.
|
||||
await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible();
|
||||
|
||||
return roomId;
|
||||
}
|
||||
|
|
348
playwright/e2e/crypto/verification.spec.ts
Normal file
348
playwright/e2e/crypto/verification.spec.ts
Normal 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;
|
||||
});
|
||||
}
|
|
@ -196,7 +196,7 @@ export const test = base.extend<
|
|||
},
|
||||
|
||||
botCreateOpts: {},
|
||||
bot: async ({ page, homeserver, botCreateOpts }, use) => {
|
||||
bot: async ({ page, homeserver, botCreateOpts, user }, use) => {
|
||||
const bot = new Bot(page, homeserver, botCreateOpts);
|
||||
await bot.prepareClient(); // eagerly register the bot
|
||||
await use(bot);
|
||||
|
|
|
@ -21,7 +21,7 @@ import { Client } from "./client";
|
|||
import { Labs } from "./labs";
|
||||
|
||||
export class ElementAppPage {
|
||||
public constructor(private readonly page: Page) {}
|
||||
public constructor(public readonly page: Page) {}
|
||||
|
||||
public labs = new Labs(this.page);
|
||||
public settings = new Settings(this.page);
|
||||
|
@ -91,6 +91,10 @@ export class ElementAppPage {
|
|||
.click();
|
||||
}
|
||||
|
||||
public async viewRoomById(roomId: string): Promise<void> {
|
||||
await this.page.goto(`/#/room/${roomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the composer element
|
||||
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
|
||||
|
|
|
@ -18,8 +18,10 @@ import { JSHandle, Page } from "@playwright/test";
|
|||
import { uniqueId } from "lodash";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type { Logger } from "matrix-js-sdk/src/logger";
|
||||
import type { AddSecretStorageKeyOpts } from "matrix-js-sdk/src/secret-storage";
|
||||
import type { Credentials, HomeserverInstance } from "../plugins/homeserver";
|
||||
import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
|
||||
import { Client } from "./client";
|
||||
|
||||
export interface CreateBotOpts {
|
||||
|
@ -60,14 +62,27 @@ const defaultCreateBotOptions = {
|
|||
bootstrapCrossSigning: true,
|
||||
} satisfies CreateBotOpts;
|
||||
|
||||
type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey };
|
||||
|
||||
export class Bot extends Client {
|
||||
public credentials?: Credentials;
|
||||
private handlePromise: Promise<JSHandle<ExtendedMatrixClient>>;
|
||||
|
||||
constructor(page: Page, private homeserver: HomeserverInstance, private readonly opts: CreateBotOpts) {
|
||||
super(page);
|
||||
this.opts = Object.assign({}, defaultCreateBotOptions, opts);
|
||||
}
|
||||
|
||||
public setCredentials(credentials: Credentials): void {
|
||||
if (this.credentials) throw new Error("Bot has already started");
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
public async getRecoveryKey(): Promise<GeneratedSecretStorageKey> {
|
||||
const client = await this.getClientHandle();
|
||||
return client.evaluate((cli) => cli.__playwright_recovery_key);
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<Credentials> {
|
||||
if (this.credentials) return this.credentials;
|
||||
// We want to pad the uniqueId but not the prefix
|
||||
|
@ -82,9 +97,36 @@ export class Bot extends Client {
|
|||
return this.credentials;
|
||||
}
|
||||
|
||||
protected async getClientHandle(): Promise<JSHandle<MatrixClient>> {
|
||||
return this.page.evaluateHandle(
|
||||
protected async getClientHandle(): Promise<JSHandle<ExtendedMatrixClient>> {
|
||||
if (this.handlePromise) return this.handlePromise;
|
||||
|
||||
this.handlePromise = this.page.evaluateHandle(
|
||||
async ({ homeserver, credentials, opts }) => {
|
||||
function getLogger(loggerName: string): Logger {
|
||||
const logger = {
|
||||
getChild: (namespace: string) => getLogger(`${loggerName}:${namespace}`),
|
||||
trace(...msg: any[]): void {
|
||||
console.trace(loggerName, ...msg);
|
||||
},
|
||||
debug(...msg: any[]): void {
|
||||
console.debug(loggerName, ...msg);
|
||||
},
|
||||
info(...msg: any[]): void {
|
||||
console.info(loggerName, ...msg);
|
||||
},
|
||||
warn(...msg: any[]): void {
|
||||
console.warn(loggerName, ...msg);
|
||||
},
|
||||
error(...msg: any[]): void {
|
||||
console.error(loggerName, ...msg);
|
||||
},
|
||||
} satisfies Logger;
|
||||
|
||||
return logger as unknown as Logger;
|
||||
}
|
||||
|
||||
const logger = getLogger(`cypress bot ${credentials.userId}`);
|
||||
|
||||
const keys = {};
|
||||
|
||||
const getCrossSigningKey = (type: string) => {
|
||||
|
@ -123,7 +165,8 @@ export class Bot extends Client {
|
|||
scheduler: new window.matrixcs.MatrixScheduler(),
|
||||
cryptoStore: new window.matrixcs.MemoryCryptoStore(),
|
||||
cryptoCallbacks,
|
||||
});
|
||||
logger,
|
||||
}) as ExtendedMatrixClient;
|
||||
|
||||
if (opts.autoAcceptInvites) {
|
||||
cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => {
|
||||
|
@ -180,5 +223,6 @@ export class Bot extends Client {
|
|||
opts: this.opts,
|
||||
},
|
||||
);
|
||||
return this.handlePromise;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
|||
ReceiptType,
|
||||
IRoomDirectoryOptions,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { Credentials } from "../plugins/homeserver";
|
||||
|
||||
export class Client {
|
||||
protected client: JSHandle<MatrixClient>;
|
||||
|
@ -100,7 +101,14 @@ export class Client {
|
|||
* @param roomId ID of the room to send the message into
|
||||
* @param content the event content to send
|
||||
*/
|
||||
public async sendMessage(roomId: string, content: IContent): Promise<ISendEventResponse> {
|
||||
public async sendMessage(roomId: string, content: IContent | string): Promise<ISendEventResponse> {
|
||||
if (typeof content === "string") {
|
||||
content = {
|
||||
body: content,
|
||||
msgtype: "m.text",
|
||||
};
|
||||
}
|
||||
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(
|
||||
(client, { roomId, content }) => {
|
||||
|
@ -177,13 +185,14 @@ export class Client {
|
|||
* Make this bot join a room by name
|
||||
* @param roomName Name of the room to join
|
||||
*/
|
||||
public async joinRoomByName(roomName: string): Promise<void> {
|
||||
public async joinRoomByName(roomName: string): Promise<string> {
|
||||
const client = await this.prepareClient();
|
||||
await client.evaluate(
|
||||
(client, { roomName }) => {
|
||||
return client.evaluate(
|
||||
async (client, { roomName }) => {
|
||||
const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName);
|
||||
if (room) {
|
||||
return client.joinRoom(room.roomId);
|
||||
await client.joinRoom(room.roomId);
|
||||
return room.roomId;
|
||||
}
|
||||
throw new Error(`Bot room join failed. Cannot find room '${roomName}'`);
|
||||
},
|
||||
|
@ -227,8 +236,29 @@ export class Client {
|
|||
|
||||
public async publicRooms(options?: IRoomDirectoryOptions): ReturnType<MatrixClient["publicRooms"]> {
|
||||
const client = await this.prepareClient();
|
||||
return await client.evaluate((client, options) => {
|
||||
return client.evaluate((client, options) => {
|
||||
return client.publicRooms(options);
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boostraps cross-signing.
|
||||
*/
|
||||
public async bootstrapCrossSigning(credentials: Credentials): Promise<void> {
|
||||
const client = await this.prepareClient();
|
||||
return client.evaluate(async (client, credentials) => {
|
||||
await client.getCrypto().bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (func) => {
|
||||
await func({
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: credentials.userId,
|
||||
},
|
||||
password: credentials.password,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, credentials);
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
Loading…
Reference in a new issue