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 d3771d102df..c5d1803b1a7 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,35 @@ 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 (!privateShouldBeEncrypted(MatrixClientPeg.safeGet())) { + microcopy = _t("settings|security|e2ee_default_disabled_warning"); + } else if (!this.state.canChangeEncryption) { + microcopy = _t("create_room|encryption_forced"); + } else if (isVideoRoom) { + microcopy = _t("create_room|encrypted_video_room_warning"); + } 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 +473,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..b593df1adb4 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -49,12 +49,19 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => { subtitle = _t("timeline|m.room.encryption|enabled_local"); } else { subtitle = _t("timeline|m.room.encryption|enabled"); + if (content["io.element.msc3414.encrypt_state_events"]) { + subtitle += " " + _t("timeline|m.room.encryption|state_enabled"); + } } 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.stateEncryption && stateEncryptedOpts) { + await enableStateEventEncryption(client, await room, opts); + } + }) .then(() => { if (opts.parentSpace) { return SpaceStore.instance.addRoomToSpace( @@ -394,6 +415,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.createOpts?.name) { + await client.setRoomName(room.roomId, opts.createOpts.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 2da705e9321..a0dcf2ef196 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", @@ -1495,6 +1498,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.", @@ -3538,6 +3543,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": "State events in this room are end-to-end encrypted.", "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 a8b4ce4ef20..10368bac4cb 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(); @@ -229,6 +230,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; @@ -791,6 +793,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 5fa02511293..a237cf64b5d 100644 --- a/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/CreateRoomDialog-test.tsx @@ -202,6 +202,7 @@ describe("", () => { name: roomName, }, encryption: true, + stateEncryption: false, parentSpace: undefined, roomType: undefined, }); @@ -263,6 +264,7 @@ describe("", () => { visibility: Visibility.Private, }, encryption: true, + stateEncryption: false, joinRule: JoinRule.Knock, parentSpace: undefined, roomType: undefined, @@ -281,6 +283,7 @@ describe("", () => { visibility: Visibility.Public, }, 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 d4789c9b93f..77f46db399a 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 expeirmental 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 in this room are end-to-end encrypted. " + + "When people join, you can verify them in their profile, just tap on their profile picture. " + + "State events in this room are end-to-end encrypted.", + ), + ); + }); + 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 06fb43cb4cf..b5768e9ae38 100644 --- a/test/unit-tests/createRoom-test.ts +++ b/test/unit-tests/createRoom-test.ts @@ -43,6 +43,52 @@ 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 createRoom(client, { + createOpts: { preset: Preset.PrivateChat, name: "Super-Secret Super-colliding Super Room" }, + encryption: true, + stateEncryption: 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", + "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 d3d6b43319c..09e4fd7b55e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13853,9 +13853,9 @@ schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0, schema-utils@^4.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"