Update globalBlacklistUnverifiedDevices on setting change (#31983)

* fix: Update `globalBlacklistUnverifiedDevices` on setting change

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* fix: Use `SettingLevel.DEVICE` filter on blacklisted device watcher

* tests: Add playwright test for blacklist unverified devices toggle

* docs: Correct test step description

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

* tests: Add test for local vs global blacklist unverified devices

* tests: Ensure local toggle overrides global toggle.

* tests: Add unit tests for blacklistUnverifiedDevices listener

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
This commit is contained in:
Skye Elliot 2026-02-10 15:26:57 +00:00 committed by GitHub
parent 81b111371f
commit f6352afc6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 348 additions and 3 deletions

View File

@ -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
*

View File

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

View File

@ -1793,8 +1793,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
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

View File

@ -1839,4 +1839,39 @@ describe("<MatrixChat />", () => {
);
});
});
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);
});
});