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 c2e3be9b3cf..dfc3006572b 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,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. */ @@ -117,6 +122,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: "", @@ -142,6 +148,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) { @@ -236,6 +243,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 +389,30 @@ 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 +476,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 5c5f1f0dc26..765796c2b32 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.msc4362.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 ed72b6963c9..81715fa6ff9 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 { + // 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,64 @@ 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, we throw since we don't want to send the events unencrypted. + await waitForRoomEncryption(room); + + // 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. + */ +async function waitForRoomEncryption(room: Room): Promise { + await new Promise((resolve, reject) => { + if (room.hasEncryptionStateEvent()) { + return resolve(); + } + + const roomState = room.getLiveTimeline().getState(Direction.Forward)!; + + const timeout = setTimeout(() => { + logger.warn("Timed out while waiting for room to enable encryption"); + roomState.off(RoomStateEvent.Update, onRoomStateUpdate); + reject(new Error("Timed out while waiting for room to enable encryption")); + }, 30000); + + const onRoomStateUpdate = (state: RoomState): void => { + if (state.getStateEvents(EventType.RoomEncryption, "")) { + roomState.off(RoomStateEvent.Update, onRoomStateUpdate); + clearTimeout(timeout); + resolve(); + } + }; + + roomState.on(RoomStateEvent.Update, onRoomStateUpdate); + }); +} + /* * 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 ddd45e35e52..509a255e1ed 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 fe57708dfa1..7ae5df14f1a 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_ROOM_SETTINGS, + 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 b0746fc422f..525f2e4ed83 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 f9ebaacbd97..8b224b655db 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -247,6 +247,7 @@ describe("", () => { createOpts: {}, name: roomName, encryption: true, + stateEncryption: false, parentSpace: undefined, roomType: undefined, }); @@ -308,6 +309,7 @@ describe("", () => { }, name: roomName, encryption: true, + stateEncryption: false, joinRule: JoinRule.Knock, parentSpace: undefined, roomType: undefined, @@ -326,6 +328,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 cacad8d47d3..7ea8f7dd8bd 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 @@ -368,6 +368,43 @@ 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. + +
+
{ ); }); + it("should show the expected texts for experimental state event encryption", async () => { + event.event.content!["io.element.msc4362.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 2d708897703..39e9e455aee 100644 --- a/test/unit-tests/createRoom-test.ts +++ b/test/unit-tests/createRoom-test.ts @@ -18,6 +18,7 @@ import { } 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, @@ -58,6 +59,146 @@ describe("createRoom", () => { }); }); + it("creates a private room 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 () => { + // Given that when we create a room, it returns true for hasEncryptionStateEvent + // (Note: this is needed since otherwise createRoom will pause waiting + // for the "m.room.encryption" event to be retuned from the server.) + + const oldCreateRoom = client.createRoom; + + // @ts-ignore Replacing createRoom + client.createRoom = async (options) => { + const { room_id: roomId } = await oldCreateRoom(options); + const room = client.getRoom(roomId); + room!.hasEncryptionStateEvent = () => true; + return { + room_id: roomId, + }; + }; + + // When we create a room asking for state encryption + await act( + async () => + await createRoom(client, { + createOpts: { + preset: Preset.PrivateChat, + }, + encryption: true, + stateEncryption: true, + name: "Super-Secret Super-colliding Super Room", + topic: "super **Topic**", + avatar: "http://example.com/myavatar.png", + }), + ); + + // Then it is created with the right m.room.encryption event + expect(oldCreateRoom).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.msc4362.encrypt_state_events": true, + }, + }, + // Room name is NOT included, since it needs to be encrypted. + ], + }); + + // And the room name, topic and avatar are set later + expect(client.setRoomName).toHaveBeenCalledWith("!1:example.org", "Super-Secret Super-colliding Super Room"); + + expect(client.setRoomTopic).toHaveBeenCalledWith( + "!1:example.org", + "super **Topic**", + "super Topic", + ); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + "!1:example.org", + "m.room.avatar", + { url: "http://example.com/myavatar.png" }, + "", + ); + }); + + it("creates a private room with state event encryption - no details", async () => { + // Given that when we create a room, it returns true for hasEncryptionStateEvent + // (Note: this is needed since otherwise createRoom will pause waiting + // for the "m.room.encryption" event to be retuned from the server.) + + const oldCreateRoom = client.createRoom; + + // @ts-ignore Replacing createRoom + client.createRoom = async (options) => { + const { room_id: roomId } = await oldCreateRoom(options); + const room = client.getRoom(roomId); + room!.hasEncryptionStateEvent = () => true; + return { + room_id: roomId, + }; + }; + + // When we create a room asking for state encryption + await act( + async () => + await createRoom(client, { + createOpts: { + preset: Preset.PrivateChat, + }, + encryption: true, + stateEncryption: true, + }), + ); + + // Then it is created with the right m.room.encryption event + expect(oldCreateRoom).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.msc4362.encrypt_state_events": true, + }, + }, + // Room name is NOT included, since it needs to be encrypted. + ], + }); + + // And the room name, topic and avatar were not set since we didn't + // supply them + expect(client.setRoomName).not.toHaveBeenCalled(); + expect(client.setRoomTopic).not.toHaveBeenCalled(); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + 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 ad3375f1bfc..3f852e62c6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1564,6 +1564,11 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.9.0.tgz#2e4fcc8809418c8670d4f0576bc4a9a235bc6c50" integrity sha512-Ao/V9w+wysZK4bh61LlKlznF10n2ZbD6KcUI46/zUMttXbmJn3ahvbzhEpwYcD+Cjy3ag5ycxLIIGkKV/fncXg== +"@element-hq/element-web-module-api@^1.8.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.9.0.tgz#2e4fcc8809418c8670d4f0576bc4a9a235bc6c50" + integrity sha512-Ao/V9w+wysZK4bh61LlKlznF10n2ZbD6KcUI46/zUMttXbmJn3ahvbzhEpwYcD+Cjy3ag5ycxLIIGkKV/fncXg== + "@element-hq/element-web-playwright-common@^2.0.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-2.1.0.tgz#86e8a5632f8cc8bb393a1ec1b793a6278cd5b2c7" @@ -4229,7 +4234,7 @@ resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c" integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm" "@vitest/expect@3.2.4": version "3.2.4"