From 9d06c6611d4a7679ae90df21d663a17fbc21a6af Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Thu, 31 Jul 2025 17:23:26 +0100 Subject: [PATCH] Provide a labs flag for encrypted state events (MSC3414) Signed-off-by: Skye Elliot --- src/MatrixClientPeg.ts | 3 + .../views/dialogs/CreateRoomDialog.tsx | 37 +++++++ .../views/messages/EncryptionEvent.tsx | 8 +- .../tabs/room/SecurityRoomSettingsTab.tsx | 5 + src/createRoom.ts | 100 ++++++++++++++---- src/i18n/strings/en_EN.json | 6 ++ src/settings/Settings.tsx | 13 +++ .../EncryptedStateEventsController.ts | 18 ++++ test/test-utils/test-utils.ts | 52 ++++++++- .../views/dialogs/CreateRoomDialog-test.tsx | 3 + .../CreateRoomDialog-test.tsx.snap | 28 +++++ .../views/messages/EncryptionEvent-test.tsx | 13 +++ test/unit-tests/createRoom-test.ts | 51 +++++++++ yarn.lock | 6 +- 14 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 src/settings/controllers/EncryptedStateEventsController.ts diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 288878cddc5..d6f1a2e5951 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -437,6 +437,9 @@ 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 }, + // We need the ability to encrypt/decrypt state events even if the lab is off, since rooms + // with state event encryption still need to function properly. + enableEncryptedStateEvents: true, 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 821182aff05..4d319660aea 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -34,6 +34,7 @@ interface IProps { defaultName?: string; parentSpace?: Room; defaultEncrypted?: boolean; + defaultStateEncrypted?: boolean; onFinished(proceed?: false): void; onFinished(proceed: true, opts: IOpts): void; } @@ -52,6 +53,10 @@ 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. + */ + isStateEncrypted: boolean; /** * The room name. */ @@ -111,6 +116,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: "", @@ -136,6 +142,7 @@ export default class CreateRoomDialog extends React.Component { createOpts.room_alias_name = alias.substring(1, alias.indexOf(":")); } else { opts.encryption = this.state.isEncrypted; + opts.stateEncryption = this.state.isStateEncrypted; } if (this.state.topic) { @@ -230,6 +237,10 @@ export default class CreateRoomDialog extends React.Component { this.setState({ isEncrypted }); }; + private onStateEncryptedChange = (isStateEncrypted: boolean): void => { + this.setState({ isStateEncrypted }); + }; + private onAliasChange = (alias: string): void => { this.setState({ alias }); }; @@ -373,6 +384,31 @@ export default class CreateRoomDialog extends React.Component { ); } + let e2eeStateSection: JSX.Element | undefined; + if ( + SettingsStore.getValue("feature_msc3414_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 = ( + + +

{microcopy}

+
+ ); + } + 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 @@ -433,6 +469,7 @@ export default class CreateRoomDialog extends React.Component { {publicPrivateLabel} {visibilitySection} {e2eeSection} + {e2eeStateSection} {aliasField} {this.advancedSettingsEnabled && (
diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index 5c5f1f0dc26..c243eea76f3 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -47,6 +47,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 (content["io.element.msc3414.encrypt_state_events"]) { + subtitle = _t("timeline|m.room.encryption|state_enabled"); } else { subtitle = _t("timeline|m.room.encryption|enabled"); } @@ -54,7 +56,11 @@ 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 37faaea5d89..28394dbda08 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -55,6 +55,7 @@ interface IState { history: HistoryVisibility; hasAliases: boolean; encrypted: boolean | null; + stateEncrypted: boolean | null; showAdvancedSection: boolean; } @@ -80,6 +81,7 @@ export default class SecurityRoomSettingsTab extends React.Component { + // 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); + } + }) .then(() => { if (opts.parentSpace) { return SpaceStore.instance.addRoomToSpace( @@ -414,6 +435,49 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro ); } +async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise { + await new Promise((resolve, reject) => { + if (room.hasEncryptionStateEvent()) { + return resolve(); + } + + const roomState = room.getLiveTimeline().getState(Direction.Forward)!; + + // Soft fail, since the room will still be functional if the initial state is not encrypted. + const timeout = setTimeout(() => { + logger.warn("Timed out while waiting for room to enable encryption"); + roomState.off(RoomStateEvent.Update, onRoomStateUpdate); + resolve(); + }, 3000); + + const onRoomStateUpdate = (state: RoomState): void => { + if (state.getStateEvents(EventType.RoomEncryption, "")) { + roomState.off(RoomStateEvent.Update, onRoomStateUpdate); + clearTimeout(timeout); + resolve(); + } + }; + + roomState.on(RoomStateEvent.Update, onRoomStateUpdate); + }); + + // Set room name + if (opts.name) { + await client.setRoomName(room.roomId, opts.name); + } + + // 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 }, ""); + } +} + /* * 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 86f9197e927..8bdba18f6cc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -578,6 +578,7 @@ "someone": "Someone", "space": "Space", "spaces": "Spaces", + "state_encryption_enabled": "Experimental state encryption enabled", "sticker": "Sticker", "stickerpack": "Stickerpack", "success": "Success", @@ -684,6 +685,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 being visible to the server.", + "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", @@ -1520,6 +1523,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", + "encrypted_state_events_description": "Enables experimental support for encrypting state events, which hides metadata such as room names and topics from being visible to the server. Enabling this lab will also enable experimental room history sharing.", "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.", @@ -3562,6 +3567,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 78758ea2779..ebfa148b890 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -50,6 +50,7 @@ import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts"; import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts"; import { type ComputedInviteConfig } from "../@types/invite-rules.ts"; +import EncryptedStateEventsController from "./controllers/EncryptedStateEventsController.ts"; export const defaultWatchManager = new WatchManager(); @@ -228,6 +229,7 @@ export interface Settings { "feature_new_room_list": IFeature; "feature_ask_to_join": IFeature; "feature_notifications": IFeature; + "feature_msc3414_encrypted_state_events": IFeature; // These are in the feature namespace but aren't actually features "feature_hidebold": IBaseSetting; @@ -780,6 +782,17 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, default: false, }, + "feature_msc3414_encrypted_state_events": { + isFeature: true, + labsGroup: LabGroup.Encryption, + controller: new EncryptedStateEventsController(), + displayName: _td("labs|encrypted_state_events"), + description: _td("labs|encrypted_state_events_description"), + supportedLevels: LEVELS_ROOM_SETTINGS, + supportedLevelsAreOrdered: true, + shouldWarn: true, + default: false, + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("settings|preferences|compact_modern"), diff --git a/src/settings/controllers/EncryptedStateEventsController.ts b/src/settings/controllers/EncryptedStateEventsController.ts new file mode 100644 index 00000000000..09a7cd10d78 --- /dev/null +++ b/src/settings/controllers/EncryptedStateEventsController.ts @@ -0,0 +1,18 @@ +/* +Copyright 2024 New Vector 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 PlatformPeg from "../../PlatformPeg"; +import { SettingLevel } from "../SettingLevel"; +import SettingsStore from "../SettingsStore"; +import SettingController from "./SettingController"; + +export default class EncryptedStateEventsController extends SettingController { + public onChange(level: SettingLevel, roomId: string | null, newValue: boolean): void { + SettingsStore.setValue("feature_share_history_on_invite", null, SettingLevel.CONFIG, newValue); + PlatformPeg.get()?.reload(); + } +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d96ee1d045e..8cb22d34810 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -17,7 +17,7 @@ import { type RoomMember, type MatrixClient, type EventTimeline, - type RoomState, + RoomState, EventType, type IEventRelation, type IUnsigned, @@ -30,6 +30,7 @@ import { JoinRule, type OidcClientConfig, type GroupCall, + type ICreateRoomOpts, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { normalize } from "matrix-js-sdk/src/utils"; @@ -84,6 +85,7 @@ export function createTestClient(): MatrixClient { const eventEmitter = new EventEmitter(); let txnId = 1; + let createdRoom: Room | undefined; const client = { getHomeserverUrl: jest.fn(), @@ -123,6 +125,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(), @@ -161,7 +164,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(), @@ -199,6 +209,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({}), @@ -211,7 +222,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(), @@ -612,10 +639,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), @@ -695,6 +723,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 6ac423570f8..b9de1d9b8e1 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -250,6 +250,7 @@ describe("", () => { createOpts: {}, name: roomName, encryption: true, + stateEncryption: false, parentSpace: undefined, roomType: undefined, }); @@ -311,6 +312,7 @@ describe("", () => { }, name: roomName, encryption: true, + stateEncryption: false, joinRule: JoinRule.Knock, parentSpace: undefined, roomType: undefined, @@ -329,6 +331,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 486ecce500c..c2d86a2fa03 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 @@ -313,6 +313,34 @@ exports[` for a private room should render not the advanced

You can't disable this later. Bridges & most bots won't work yet.

+
+ +
+ Encrypt state events +
+
+
+
+
+
+

+ Enables experimental support for encrypting state events, which hides metadata such as room names and topics from being visible to the server. +

{ ); }); + it("should show the expected texts for experimental state event encryption", async () => { + event.event.content!["io.element.msc3414.encrypt_state_events"] = true; + renderEncryptionEvent(client, event); + await waitFor(() => + checkTexts( + "Experimental state encryption 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.", + ), + ); + }); + describe("with same previous algorithm", () => { beforeEach(() => { jest.spyOn(event, "getPrevContent").mockReturnValue({ diff --git a/test/unit-tests/createRoom-test.ts b/test/unit-tests/createRoom-test.ts index 4c0ee86278f..9d46cb5fa81 100644 --- a/test/unit-tests/createRoom-test.ts +++ b/test/unit-tests/createRoom-test.ts @@ -10,6 +10,7 @@ import { mocked, type Mocked } from "jest-mock"; import { type MatrixClient, type Device, Preset, RoomType } from "matrix-js-sdk/src/matrix"; import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; +import { act } from "jest-matrix-react"; import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg, getMockClientWithEventEmitter } from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; @@ -43,6 +44,56 @@ describe("createRoom", () => { }); }); + it("creates a private with encryption", async () => { + await createRoom(client, { createOpts: { preset: Preset.PrivateChat }, encryption: true }); + + expect(client.createRoom).toHaveBeenCalledWith({ + preset: "private_chat", + visibility: "private", + initial_state: [ + { state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } }, + { + state_key: "", + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + }); + + it("creates a private room with state event encryption", async () => { + await act( + async () => + await createRoom(client, { + createOpts: { preset: Preset.PrivateChat }, + encryption: true, + stateEncryption: true, + name: "Super-Secret Super-colliding Super Room", + }), + ); + + expect(client.createRoom).toHaveBeenCalledWith({ + preset: "private_chat", + visibility: "private", + initial_state: [ + { state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } }, + { + state_key: "", + type: "m.room.encryption", + content: { + "algorithm": "m.megolm.v1.aes-sha2", + "io.element.msc3414.encrypt_state_events": true, + }, + }, + // Room name is NOT included, since it needs to be encrypted. + ], + }); + + expect(client.setRoomName).toHaveBeenCalledWith("!1:example.org", "Super-Secret Super-colliding Super Room"); + }); + it("creates a private room in a space", async () => { const roomId = await createRoom(client, { roomType: RoomType.Space }); const parentSpace = client.getRoom(roomId!)!; diff --git a/yarn.lock b/yarn.lock index 9c5cab1c20a..57c5fb18b17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12138,9 +12138,9 @@ schema-utils@^4.3.0, schema-utils@^4.3.3: ajv-keywords "^5.1.0" sdp-transform@^2.14.1: - version "2.14.2" - resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.2.tgz#d2cee6a1f7abe44e6332ac6cbb94e8600f32d813" - integrity sha512-icY6jVao7MfKCieyo1AyxFYm1baiM+fA00qW/KrNNVlkxHAd34riEKuEkUe4bBb3gJwLJZM+xT60Yj1QL8rHiA== + version "2.15.0" + resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.15.0.tgz#79d37a2481916f36a0534e07b32ceaa87f71df42" + integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw== seedrandom@^3.0.5: version "3.0.5"