diff --git a/docs/labs.md b/docs/labs.md index 60f35dd4a4..a4ab78b08b 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -112,3 +112,25 @@ Enables knock feature for rooms. This allows users to ask to join a room. ## New room list (`feature_new_room_list`) [In Development] Enable the new room list that is currently in development. + +## Exclude insecure devices when sending/receiving messages (`feature_exclude_insecure_devices`) + +Do not send or receive messages to/from devices that are not properly verified. Users with unverified devices will not +receive your messages at all on those devices, and if they send messages, you will not be able to read them, but you +will be aware that a message exists. + +## Share encrypted history with new members (`feature_share_history_on_invite`) [In Development] + +When inviting users to an encrypted room with shared history (i.e. a room with the "Who can read history?" setting set +to "Members only (since the point in time of selecting this option)"), send the keys for previous messages to the +invitee so they can read them. + +Both the inviter and the invitee must set this labs flag, before the invitation is sent. + +## Encrypted state events (MSC4362) (`feature_msc4362_encrypted_state_events`) + +Encrypt most of the state events in the room, including the room name and topic. + +WARNING: this means that users joining a room who do not have access to its history will not be able to see the name or +topic of the room, or any other room state information. It also means the room name and topic are not available before +joining a room. diff --git a/playwright/e2e/room/create-room.spec.ts b/playwright/e2e/room/create-room.spec.ts index 750256e211..5539d6c5ae 100644 --- a/playwright/e2e/room/create-room.spec.ts +++ b/playwright/e2e/room/create-room.spec.ts @@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type Page } from "playwright-core"; + import { SettingLevel } from "../../../src/settings/SettingLevel"; import { UIFeature } from "../../../src/settings/UIFeature"; import { test, expect } from "../../element-web-test"; @@ -110,4 +112,107 @@ test.describe("Create Room", () => { await expect(header).toContainText(name); }); }); + + test.describe("when the encrypted state labs flag is turned off", () => { + test.use({ labsFlags: [] }); + + test("creates a room without encrypted state", { tag: "@screenshot" }, async ({ page, user: _user }) => { + // When we start to create a room + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(name); + + // Then there is no Encrypt state events button + await expect(page.getByRole("checkbox", { name: "Encrypt state events" })).not.toBeVisible(); + + // And when we create the room + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created a normal encrypted room, without encrypted state + await expect(page.getByText("Encryption enabled")).toBeVisible(); + await expect(page.getByText("State encryption enabled")).not.toBeVisible(); + + // And the room name state event is not encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Original event source")).toBeVisible(); + await expect(page.getByText("Decrypted event source")).not.toBeVisible(); + }); + }); + + test.describe("when the encrypted state labs flag is turned on", () => { + test.use({ labsFlags: ["feature_msc4362_encrypted_state_events"] }); + + test( + "creates a room with encrypted state if we check the box", + { tag: "@screenshot" }, + async ({ page, user: _user }) => { + // Given we check the Encrypted State checkbox + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked(); + await page.getByRole("switch", { name: "Encrypt state events" }).click(); + await expect(page.getByRole("switch", { name: "Encrypt state events" })).toBeChecked(); + + // When we create a room + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created an encrypted state room + await expect(page.getByText("State encryption enabled")).toBeVisible(); + + // And it has the correct name + await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible(); + + // And the room name state event is encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Decrypted event source")).toBeVisible(); + }, + ); + + test( + "creates a room without encrypted state if we don't check the box", + { tag: "@screenshot" }, + async ({ page, user: _user }) => { + // Given we did not check the Encrypted State checkbox + await page.getByRole("button", { name: "New conversation", exact: true }).click(); + await page.getByRole("menuitem", { name: "New room" }).click(); + await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked(); + + // And it is off by default + await expect(page.getByRole("switch", { name: "Encrypt state events" })).not.toBeChecked(); + + // When we create a room + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + + // Then we created a normal encrypted room, without encrypted state + await expect(page.getByText("Encryption enabled")).toBeVisible(); + await expect(page.getByText("State encryption enabled")).not.toBeVisible(); + + // And it has the correct name + await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible(); + + // And the room name state event is not encrypted + await viewSourceOnRoomNameEvent(page); + await expect(page.getByText("Original event source")).toBeVisible(); + await expect(page.getByText("Decrypted event source")).not.toBeVisible(); + }, + ); + }); }); + +async function viewSourceOnRoomNameEvent(page: Page) { + await page + .getByRole("listitem") + .filter({ hasText: "created and configured the room" }) + .getByRole("button", { name: "expand" }) + .click(); + + await page + .getByRole("listitem") + .filter({ hasText: "changed the room name to" }) + .getByRole("button", { name: "Options" }) + .click(); + + await page.getByRole("menuitem", { name: "View source" }).click(); +} diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 288878cddc..8148509ef1 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -437,6 +437,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { // These are always installed regardless of the labs flag so that cross-signing features // can toggle on without reloading and also be accessed immediately after login. cryptoCallbacks: { ...crossSigningCallbacks }, + enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"), roomNameGenerator: (_: string, state: RoomNameState) => { switch (state.type) { case RoomNameType.Generated: diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index c2e3be9b3c..5321390fca 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -40,6 +40,7 @@ interface IProps { defaultName?: string; parentSpace?: Room; defaultEncrypted?: boolean; + defaultStateEncrypted?: boolean; onFinished(proceed?: false): void; onFinished(proceed: true, opts: IOpts): void; } @@ -58,6 +59,11 @@ interface IState { * Indicates whether end-to-end encryption is enabled for the room. */ isEncrypted: boolean; + /** + * Indicates whether end-to-end state encryption is enabled for this room. + * See MSC4362. Available if feature_msc4362_encrypted_state_events is enabled. + */ + isStateEncrypted: boolean; /** * The room name. */ @@ -117,6 +123,7 @@ export default class CreateRoomDialog extends React.Component { this.state = { isPublicKnockRoom: defaultPublic || false, isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli), + isStateEncrypted: this.props.defaultStateEncrypted ?? false, joinRule, name: this.props.defaultName || "", topic: "", @@ -141,7 +148,10 @@ export default class CreateRoomDialog extends React.Component { const { alias } = this.state; createOpts.room_alias_name = alias.substring(1, alias.indexOf(":")); } else { + const encryptedStateFeature = SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false); + opts.encryption = this.state.isEncrypted; + opts.stateEncryption = encryptedStateFeature && this.state.isStateEncrypted; } if (this.state.topic) { @@ -236,6 +246,10 @@ export default class CreateRoomDialog extends React.Component { this.setState({ isEncrypted: evt.target.checked }); }; + private onStateEncryptedChange: ChangeEventHandler = (evt): void => { + this.setState({ isStateEncrypted: evt.target.checked }); + }; + private onAliasChange = (alias: string): void => { this.setState({ alias }); }; @@ -378,6 +392,29 @@ export default class CreateRoomDialog extends React.Component { ); } + let e2eeStateSection: JSX.Element | undefined; + if ( + SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false) && + this.state.joinRule !== JoinRule.Public + ) { + let microcopy: string; + if (!this.state.canChangeEncryption) { + microcopy = _t("create_room|encryption_forced"); + } else { + microcopy = _t("create_room|state_encrypted_warning"); + } + e2eeStateSection = ( + + ); + } + let federateLabel = _t("create_room|unfederated_label_default_off"); if (SdkConfig.get().default_federate === false) { // We only change the label if the default setting is different to avoid jarring text changes to the @@ -441,6 +478,7 @@ export default class CreateRoomDialog extends React.Component { {visibilitySection} {e2eeSection} + {e2eeStateSection} {aliasField} {this.advancedSettingsEnabled && (
diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index 5c5f1f0dc2..cf8b00db39 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -40,6 +40,9 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => { let subtitle: string; const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); const room = cli?.getRoom(roomId); + + const stateEncrypted = content["io.element.msc4362.encrypt_state_events"] && cli.enableEncryptedStateEvents; + if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) { subtitle = _t("timeline|m.room.encryption|parameters_changed"); } else if (dmPartner) { @@ -47,6 +50,8 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => { subtitle = _t("timeline|m.room.encryption|enabled_dm", { displayName }); } else if (room && isLocalRoom(room)) { subtitle = _t("timeline|m.room.encryption|enabled_local"); + } else if (stateEncrypted) { + subtitle = _t("timeline|m.room.encryption|state_enabled"); } else { subtitle = _t("timeline|m.room.encryption|enabled"); } @@ -54,7 +59,7 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => { return ( diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index ed72b6963c..5f71ecd81a 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -54,6 +54,7 @@ interface IState { history: HistoryVisibility; hasAliases: boolean; encrypted: boolean | null; + stateEncrypted: boolean | null; showAdvancedSection: boolean; } @@ -79,6 +80,7 @@ export default class SecurityRoomSettingsTab extends React.Component{_t("room_settings|security|encryption_forced")} )} + {isStateEncrypted && ( + + )} {encryptionSettings} )} diff --git a/src/createRoom.ts b/src/createRoom.ts index b73c06d8db..30020bc2ce 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -21,8 +21,12 @@ import { Preset, RestrictedAllowType, Visibility, + Direction, + RoomStateEvent, + type RoomState, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { type RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; import Modal, { type IHandle } from "./Modal"; import { _t, UserFriendlyError } from "./languageHandler"; @@ -44,6 +48,7 @@ import { doesRoomVersionSupport, PreferredRoomVersions } from "./utils/Preferred import SettingsStore from "./settings/SettingsStore"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto"; import { ElementCallMemberEventType } from "./call-types"; +import { htmlSerializeFromMdIfNeeded } from "./editor/serialize"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -66,6 +71,10 @@ export interface IOpts { spinner?: boolean; guestAccess?: boolean; encryption?: boolean; + /** + * Encrypt state events as per MSC4362 + */ + stateEncryption?: boolean; inlineErrors?: boolean; andView?: boolean; avatar?: File | string; // will upload if given file, else mxcUrl is needed @@ -113,6 +122,7 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro if (opts.spinner === undefined) opts.spinner = true; if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.encryption === undefined) opts.encryption = false; + if (opts.stateEncryption === undefined) opts.stateEncryption = false; if (client.isGuest()) { dis.dispatch({ action: "require_registration" }); @@ -207,12 +217,16 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro } if (opts.encryption) { + const content: RoomEncryptionEventContent = { + algorithm: MEGOLM_ENCRYPTION_ALGORITHM, + }; + if (opts.stateEncryption) { + content["io.element.msc4362.encrypt_state_events"] = true; + } createOpts.initial_state.push({ type: "m.room.encryption", state_key: "", - content: { - algorithm: MEGOLM_ENCRYPTION_ALGORITHM, - }, + content, }); } @@ -256,24 +270,28 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro }); } - if (opts.name) { - createOpts.name = opts.name; - } - - if (opts.topic) { - createOpts.topic = opts.topic; - } - - if (opts.avatar) { - let url = opts.avatar; - if (opts.avatar instanceof File) { - ({ content_uri: url } = await client.uploadContent(opts.avatar)); + // If we are not encrypting state, copy name, topic, avatar over to + // createOpts so we pass them in when we call Client.createRoom(). + if (!opts.stateEncryption) { + if (opts.name) { + createOpts.name = opts.name; } - createOpts.initial_state.push({ - type: EventType.RoomAvatar, - content: { url }, - }); + if (opts.topic) { + createOpts.topic = opts.topic; + } + + if (opts.avatar) { + let url = opts.avatar; + if (opts.avatar instanceof File) { + ({ content_uri: url } = await client.uploadContent(opts.avatar)); + } + + createOpts.initial_state.push({ + type: EventType.RoomAvatar, + content: { url }, + }); + } } if (opts.historyVisibility) { @@ -330,6 +348,13 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro if (opts.dmUserId) await Rooms.setDMRoom(client, roomId, opts.dmUserId); }) + .then(async () => { + // We need to set up initial state manually if state encryption is enabled, since it needs + // to be encrypted. + if (opts.encryption && opts.stateEncryption) { + await enableStateEventEncryption(client, await room, opts); + } + }) .finally(function () { if (modal) modal.close(); }) @@ -401,6 +426,73 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro ); } +async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise { + // Don't send our state events until encryption is enabled. If this times + // out after 30 seconds, we throw since we don't want to send the events + // unencrypted. + await waitForRoomEncryption(room, 30000); + + // Set room name + if (opts.name) { + await client.setRoomName(room.roomId, opts.name); + } + + // Set room topic + if (opts.topic) { + const htmlTopic = htmlSerializeFromMdIfNeeded(opts.topic, { forceHTML: false }); + await client.setRoomTopic(room.roomId, opts.topic, htmlTopic); + } + + // Set room avatar + if (opts.avatar) { + let url: string; + if (opts.avatar instanceof File) { + ({ content_uri: url } = await client.uploadContent(opts.avatar)); + } else { + url = opts.avatar; + } + await client.sendStateEvent(room.roomId, EventType.RoomAvatar, { url }, ""); + } +} + +/** + * Wait until the supplied room has an `m.room.encryption` event, or time out + * after 30 seconds. + */ +export async function waitForRoomEncryption(room: Room, waitTimeMs: number): Promise { + if (room.hasEncryptionStateEvent()) { + return; + } + + // Start a 30s timeout and return "timed_out" if we hit it + const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers(); + const timeout = setTimeout(timeoutResolve, waitTimeMs, "timed_out"); + + // Listen for a RoomEncryption state update and return + // "received_encryption_state" if we get it + const roomState = room.getLiveTimeline().getState(Direction.Forward)!; + const { promise: stateUpdatePromise, resolve: stateUpdateResolve } = Promise.withResolvers(); + const onRoomStateUpdate = (state: RoomState): void => { + if (state.getStateEvents(EventType.RoomEncryption, "")) { + stateUpdateResolve("received_encryption_state"); + } + }; + roomState.on(RoomStateEvent.Update, onRoomStateUpdate); + + // Wait for one of the above to happen + const resolution = await Promise.race([timeoutPromise, stateUpdatePromise]); + + // Clear the listener and the timeout + roomState.off(RoomStateEvent.Update, onRoomStateUpdate); + clearTimeout(timeout); + + // Fail if we hit the timeout + if (resolution === "timed_out") { + logger.warn("Timed out while waiting for room to enable encryption"); + throw new Error("Timed out while waiting for room to enable encryption"); + } +} + /* * Ensure that for every user in a room, there is at least one device that we * can encrypt to. diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ddd45e35e5..509a255e1e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -579,6 +579,7 @@ "someone": "Someone", "space": "Space", "spaces": "Spaces", + "state_encryption_enabled": "Experimental state encryption enabled", "sticker": "Sticker", "stickerpack": "Stickerpack", "success": "Success", @@ -686,6 +687,8 @@ "join_rule_restricted_label": "Everyone in will be able to find and join this room.", "name_validation_required": "Please enter a name for the room", "room_visibility_label": "Room visibility", + "state_encrypted_warning": "Enables experimental support for encrypting state events, which hides metadata such as room names and topics from the server. This metadata will also be hidden from people joining rooms later, and people whose clients do not support MSC4362.", + "state_encryption_label": "Encrypt state events", "title_private_room": "Create a private room", "title_public_room": "Create a public room", "title_video_room": "Create a video room", @@ -1522,6 +1525,8 @@ "dynamic_room_predecessors": "Dynamic room predecessors", "dynamic_room_predecessors_description": "Enable MSC3946 (to support late-arriving room archives)", "element_call_video_rooms": "Element Call video rooms", + "encrypted_state_events": "Encrypted state events (MSC4362)", + "encrypted_state_events_description": "Enables experimental support for encrypting state events, which hides metadata such as room names and topics from the server. This metadata will also be hidden from people joining rooms later, and people whose clients do not support MSC4362.", "exclude_insecure_devices": "Exclude insecure devices when sending/receiving messages", "exclude_insecure_devices_description": "When this mode is enabled, encrypted messages will not be shared with unverified devices, and messages from unverified devices will be shown as an error. Note that if you enable this mode, you may be unable to communicate with users who have not verified their devices.", "experimental_description": "Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. Learn more.", @@ -3579,6 +3584,7 @@ "enabled_dm": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their profile picture.", "enabled_local": "Messages in this chat will be end-to-end encrypted.", "parameters_changed": "Some encryption parameters have been changed.", + "state_enabled": "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", "unsupported": "The encryption used by this room isn't supported." }, "m.room.guest_access": { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fe57708dfa..2fdc1a79a2 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -229,6 +229,7 @@ export interface Settings { "feature_new_room_list": IFeature; "feature_ask_to_join": IFeature; "feature_notifications": IFeature; + "feature_msc4362_encrypted_state_events": IFeature; // These are in the feature namespace but aren't actually features "feature_hidebold": IBaseSetting; @@ -788,6 +789,16 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, default: false, }, + "feature_msc4362_encrypted_state_events": { + isFeature: true, + labsGroup: LabGroup.Encryption, + displayName: _td("labs|encrypted_state_events"), + description: _td("labs|encrypted_state_events_description"), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + supportedLevelsAreOrdered: true, + shouldWarn: true, + default: false, + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("settings|preferences|compact_modern"), diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index b0746fc422..525f2e4ed8 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -17,7 +17,7 @@ import { type IEvent, type RoomMember, type MatrixClient, - type RoomState, + RoomState, EventType, type IEventRelation, type IUnsigned, @@ -31,6 +31,7 @@ import { type OidcClientConfig, type GroupCall, HistoryVisibility, + type ICreateRoomOpts, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { normalize } from "matrix-js-sdk/src/utils"; @@ -85,6 +86,7 @@ export function createTestClient(): MatrixClient { const eventEmitter = new EventEmitter(); let txnId = 1; + let createdRoom: Room | undefined; const client = { getHomeserverUrl: jest.fn(), @@ -124,6 +126,7 @@ export function createTestClient(): MatrixClient { getDeviceVerificationStatus: jest.fn(), resetKeyBackup: jest.fn(), isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), + isStateEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]), setDeviceIsolationMode: jest.fn(), prepareToEncrypt: jest.fn(), @@ -162,7 +165,14 @@ export function createTestClient(): MatrixClient { }), getPushActionsForEvent: jest.fn(), - getRoom: jest.fn().mockImplementation((roomId) => mkStubRoom(roomId, "My room", client)), + getRoom: jest.fn().mockImplementation((roomId) => { + // If the test called `createRoom`, return the mocked room it created. + if (createdRoom) { + return createdRoom; + } else { + return mkStubRoom(roomId, "My room", client); + } + }), getRooms: jest.fn().mockReturnValue([]), getVisibleRooms: jest.fn().mockReturnValue([]), loginFlows: jest.fn(), @@ -201,6 +211,7 @@ export function createTestClient(): MatrixClient { setAccountData: jest.fn(), deleteAccountData: jest.fn(), setRoomAccountData: jest.fn(), + setRoomName: jest.fn(), setRoomTopic: jest.fn(), setRoomReadMarkers: jest.fn().mockResolvedValue({}), sendTyping: jest.fn().mockResolvedValue({}), @@ -213,7 +224,23 @@ export function createTestClient(): MatrixClient { getRoomHierarchy: jest.fn().mockReturnValue({ rooms: [], }), - createRoom: jest.fn().mockResolvedValue({ room_id: "!1:example.org" }), + createRoom: jest.fn(async (createOpts?: ICreateRoomOpts) => { + const initialState = createOpts?.initial_state?.map((event, i) => + mkEvent({ + ...event, + room: "!1:example.org", + user: "@user:example.com", + event: true, + }), + ); + createdRoom = mkStubRoom( + "!1:example.org", + "My room", + client, + initialState && mkRoomState("!1:example.org", initialState), + ); + return { room_id: "!1:example.org" }; + }), setPowerLevel: jest.fn().mockResolvedValue(undefined), pushRules: {}, decryptEventIfNeeded: () => Promise.resolve(), @@ -616,10 +643,11 @@ export function mkStubRoom( roomId: string | null | undefined = null, name: string | undefined, client: MatrixClient | undefined, + state?: RoomState | undefined, ): Room { const stubTimeline = { getEvents: (): MatrixEvent[] => [], - getState: (): RoomState | undefined => undefined, + getState: (): RoomState | undefined => state, } as unknown as EventTimeline; return { canInvite: jest.fn().mockReturnValue(false), @@ -701,6 +729,22 @@ export function mkStubRoom( } as unknown as Room; } +export function mkRoomState( + roomId: string = "!1:example.org", + stateEvents: MatrixEvent[] = [], + members: RoomMember[] = [], +): RoomState { + const roomState = new RoomState(roomId); + + roomState.setStateEvents(stateEvents); + + for (const member of members) { + roomState.members[member.userId] = member; + } + + return roomState; +} + export function mkServerConfig( hsUrl: string, isUrl: string, diff --git a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx index f9ebaacbd9..9f648bb32e 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, screen, within } from "jest-matrix-react"; +import { act, fireEvent, render, screen, within } from "jest-matrix-react"; import { type Room, JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix"; import CreateRoomDialog from "../../../../../src/components/views/dialogs/CreateRoomDialog"; @@ -247,6 +247,7 @@ describe("", () => { createOpts: {}, name: roomName, encryption: true, + stateEncryption: false, parentSpace: undefined, roomType: undefined, }); @@ -260,6 +261,29 @@ describe("", () => { await flushPromises(); expect(asFragment()).toMatchSnapshot(); }); + + describe("when the state encryption labs flag is on", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_msc4362_encrypted_state_events", + ); + }); + + it("should turn on state encryption when toggled", async () => { + // Given we have the create room dialog open + const { asFragment } = getComponent(); + await flushPromises(); + expect(asFragment()).toMatchSnapshot(); + + // When I click the Encrypt state events toggle + const toggle = screen.getByRole("switch", { name: "Encrypt state events" }); + expect(toggle).not.toBeChecked(); + act(() => toggle.click()); + + // Then it changes state + expect(toggle).toBeChecked(); + }); + }); }); describe("for a knock room", () => { @@ -308,6 +332,7 @@ describe("", () => { }, name: roomName, encryption: true, + stateEncryption: false, joinRule: JoinRule.Knock, parentSpace: undefined, roomType: undefined, @@ -326,6 +351,7 @@ describe("", () => { }, name: roomName, encryption: true, + stateEncryption: false, joinRule: JoinRule.Knock, parentSpace: undefined, roomType: undefined, diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap index d7e41df814..314aafb29c 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/CreateRoomDialog-test.tsx.snap @@ -390,6 +390,273 @@ exports[` for a private room should render not the advanced +
+
+
+ +
+
+
+
+ + + Enables experimental support for encrypting state events, which hides metadata such as room names and topics from the server. This metadata will also be hidden from people joining rooms later, and people whose clients do not support MSC4362. + +
+
+ +
+
+ + + + +
+
+
+
+ +`; + +exports[` for a private room when the state encryption labs flag is on should turn on state encryption when toggled 1`] = ` + +
+