diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index 4aab27f51a..792dcedd63 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import { expect, type JSHandle, type Page } from "@playwright/test"; +import { type ICreateRoomOpts, type MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import type { CryptoEvent, EmojiMapping, @@ -499,6 +499,51 @@ export const verify = async (app: ElementAppPage, bob: Bot) => { await roomInfo.getByRole("button", { name: "Got it" }).click(); }; +/** + * Verify two instances of the Element app by emoji. + * @param aliceElementApp + * @param bobElementApp + */ +export const verifyApp = async ( + aliceDisplayName: string, + aliceElementApp: ElementAppPage, + bobDisplayName: string, + bobElementApp: ElementAppPage, +) => { + // Alice opens room info and starts verification. + const aliceRoomInfo = aliceElementApp.page.locator(".mx_RightPanel"); + if (await aliceRoomInfo.isHidden()) { + await aliceElementApp.toggleRoomInfoPanel(); + } + + await aliceElementApp.page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click(); + await aliceRoomInfo.getByText("Bob").click(); + await aliceRoomInfo.getByRole("button", { name: "Verify" }).click(); + await aliceRoomInfo.getByRole("button", { name: "Start Verification" }).click(); + + // Navigate to the DM created by Alice and accept the invite + const oldRoomId = await bobElementApp.getCurrentRoomIdFromUrl(); + await bobElementApp.viewRoomByName(aliceDisplayName); + await bobElementApp.page.getByRole("button", { name: "Start chatting" }).click(); + + // Bob accepts the verification request. + await bobElementApp.page.getByRole("button", { name: "Verify" }).click({ timeout: 5000 }); + + // This requires creating a DM, so can take a while. Give it a longer timeout. + await aliceRoomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); + await bobElementApp.page.getByRole("button", { name: "Verify by emoji" }).click(); + + await aliceRoomInfo.getByRole("button", { name: "They match" }).click(); + await bobElementApp.page.getByRole("button", { name: "They match" }).click(); + + // Assert the verification was successful. + await expect(aliceRoomInfo.getByText(`You've successfully verified ${bobDisplayName}!`)).toBeVisible(); + await expect(bobElementApp.page.getByText(`You've successfully verified ${aliceDisplayName}!`)).toBeVisible(); + + // Navigate Bob back to the old room. + await bobElementApp.viewRoomById(oldRoomId); +}; + /** * Wait for a verifier to exist for a VerificationRequest * diff --git a/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts b/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts new file mode 100644 index 0000000000..6c20af2d9a --- /dev/null +++ b/playwright/e2e/settings/encryption-user-tab/other-devices.spec.ts @@ -0,0 +1,253 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { createNewInstance } from "@element-hq/element-web-playwright-common"; + +import { test, expect } from "./index"; +import { ElementAppPage } from "../../../pages/ElementAppPage"; +import { createRoom, sendMessageInCurrentRoom, verifyApp } from "../../crypto/utils"; + +test.describe("Other people's devices section in Encryption tab", () => { + test.use({ + displayName: "alice", + }); + + test("unverified devices should be able to decrypt while global blacklist is not toggled", async ({ + page: alicePage, + app: aliceElementApp, + homeserver, + browser, + user: aliceCredentials, + }, testInfo) => { + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Create a second browser instance. + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Create the room and invite bob + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice sends a message, which Bob should be able to decrypt + await sendMessageInCurrentRoom(alicePage, "Decryptable"); + await expect(bobPage.getByText("Decryptable")).toBeVisible(); + }); + + test("unverified devices should not be able to decrypt while global blacklist is toggled", async ({ + page: alicePage, + app: aliceElementApp, + homeserver, + browser, + user: aliceCredentials, + util, + }, testInfo) => { + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Enable blacklist toggle. + const dialog = await util.openEncryptionTab(); + const blacklistToggle = dialog.getByRole("switch", { + name: "In encrypted rooms, only send messages to verified users", + }); + await blacklistToggle.scrollIntoViewIfNeeded(); + await expect(blacklistToggle).toBeVisible(); + await blacklistToggle.click(); + await aliceElementApp.settings.closeDialog(); + + // Create a second browser instance. + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Create the room and invite bob + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice sends a message, which Bob should not be able to decrypt + await sendMessageInCurrentRoom(alicePage, "Undecryptable"); + await expect( + bobPage.getByText( + "The sender has blocked you from receiving this message because your device is unverified", + ), + ).toBeVisible(); + }); + + test("verified devices should be able to decrypt while global blacklist is toggled", async ({ + page: alicePage, + app: aliceElementApp, + homeserver, + browser, + user: aliceCredentials, + util, + }, testInfo) => { + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Enable blacklist toggle. + const dialog = await util.openEncryptionTab(); + const blacklistToggle = dialog.getByRole("switch", { + name: "In encrypted rooms, only send messages to verified users", + }); + await blacklistToggle.scrollIntoViewIfNeeded(); + await expect(blacklistToggle).toBeVisible(); + await blacklistToggle.click(); + await aliceElementApp.settings.closeDialog(); + + // Create a second browser instance. + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Create the room and invite bob + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite and dismisses the warnings. + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable notifications + await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage + await bobPage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 + + // Perform verification. + await verifyApp("alice", aliceElementApp, "bob", bobElementApp); + + // Alice sends a message, which Bob should be able to decrypt + await sendMessageInCurrentRoom(alicePage, "Decryptable"); + await expect(bobPage.getByText("Decryptable")).toBeVisible(); + }); + + test("setting per-room unverified blacklist toggle does not affect other rooms", async ({ + page: alicePage, + app: aliceElementApp, + homeserver, + browser, + user: aliceCredentials, + }, testInfo) => { + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Create a second browser instance. + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Alice creates the room and invite Bob. + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite. + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice configures her client to blacklist unverified users in this room. + const dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy"); + await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click(); + await aliceElementApp.settings.closeDialog(); + + // Alice sends a message which Bob should not be able to decrypt. + await sendMessageInCurrentRoom(alicePage, "Undecryptable"); + await expect( + bobPage.getByText( + "The sender has blocked you from receiving this message because your device is unverified", + ), + ).toBeVisible(); + + // Alice dismisses key storage warnings, as they now hide the "New conversation" button. + await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage + await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 + + // Alice creates a second room and invites Bob. + await createRoom(alicePage, "TestRoom2", true); + await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite. + await bobPage.getByRole("option", { name: "TestRoom2" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice sends a message in the new room, which Bob should be able to decrypt. + await sendMessageInCurrentRoom(alicePage, "Decryptable"); + await expect(bobPage.getByText("Decryptable")).toBeVisible(); + }); + + test("setting per-room unverified blacklist toggle overrides global toggle", async ({ + page: alicePage, + app: aliceElementApp, + homeserver, + browser, + user: aliceCredentials, + util, + }, testInfo) => { + await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials); + + // Enable blacklist toggle. + let dialog = await util.openEncryptionTab(); + const blacklistToggle = dialog.getByRole("switch", { + name: "In encrypted rooms, only send messages to verified users", + }); + await blacklistToggle.scrollIntoViewIfNeeded(); + await expect(blacklistToggle).toBeVisible(); + await blacklistToggle.click(); + await aliceElementApp.settings.closeDialog(); + + // Create a second browser instance. + const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob"); + const bobPage = await createNewInstance(browser, bobCredentials, {}); + const bobElementApp = new ElementAppPage(bobPage); + await bobElementApp.client.bootstrapCrossSigning(bobCredentials); + + // Alice creates the room and invite Bob. + await createRoom(alicePage, "TestRoom", true); + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite. + await bobPage.getByRole("option", { name: "TestRoom" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice configures her client to allow sending to unverified users in this room. + dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy"); + await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click(); + await aliceElementApp.settings.closeDialog(); + + // Alice sends a message which Bob should be able to decrypt. + await sendMessageInCurrentRoom(alicePage, "Decryptable"); + await expect(bobPage.getByText("Decryptable")).toBeVisible(); + + // Alice dismisses key storage warnings, as they now hide the "New conversation" button. + await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage + await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2 + + // Alice creates a second room and invites Bob. + await createRoom(alicePage, "TestRoom2", true); + await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below + await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId); + + // Bob accepts the invite. + await bobPage.getByRole("option", { name: "TestRoom2" }).click(); + await bobPage.getByRole("button", { name: "Accept" }).click(); + + // Alice sends a message in the new room, which Bob should not be able to decrypt. + await sendMessageInCurrentRoom(alicePage, "Undecryptable"); + await expect( + bobPage.getByText( + "The sender has blocked you from receiving this message because your device is unverified", + ), + ).toBeVisible(); + }); +}); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8a9ffdd115..4c76bc8e99 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1793,8 +1793,20 @@ export default class MatrixChat extends React.PureComponent { const crypto = cli.getCrypto(); if (crypto) { - const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices"); - crypto.globalBlacklistUnverifiedDevices = blacklistEnabled; + crypto.globalBlacklistUnverifiedDevices = SettingsStore.getValueAt( + SettingLevel.DEVICE, + "blacklistUnverifiedDevices", + ); + SettingsStore.watchSetting( + "blacklistUnverifiedDevices", + null, + (_settingName, _roomId, atLevel, blacklistEnabled: boolean) => { + if (atLevel != SettingLevel.DEVICE) { + return; + } + crypto.globalBlacklistUnverifiedDevices = blacklistEnabled; + }, + ); } // Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 24b190c0f8..8bffcda5d6 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -1839,4 +1839,39 @@ describe("", () => { ); }); }); + + describe("blacklistUnverifiedDevices settings", () => { + beforeEach(async () => { + mockPlatformPeg(); + getComponent({}); + // Force a client start manually to avoid needing to go through the login flow. + defaultDispatcher.dispatch({ + action: Action.ClientStarted, + }); + await flushPromises(); + }); + + afterEach(() => { + SettingsStore.reset(); + }); + + it("should ignore room-device-level blacklistUnverifiedDevices updates", async () => { + // Set the blacklist toggle at a room-specific level ... + await SettingsStore.setValue( + "blacklistUnverifiedDevices", + "!room:example.com", + SettingLevel.ROOM_DEVICE, + true, + ); + // ... which SHOULD NOT affect the global blacklist property. + expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeFalsy(); + }, 10e3); + + it("should update globalBlacklistUnverifiedDevices on device-level updates", async () => { + // Set the blacklist toggle at a device level ... + await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, true); + // shich SHOULD affect the global blacklist property. + expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeTruthy(); + }, 10e3); + }); });