Skip to content

Commit 9d06c66

Browse files
kaylendogandybalaam
authored andcommitted
Provide a labs flag for encrypted state events (MSC3414)
Signed-off-by: Skye Elliot <[email protected]>
1 parent 017aee9 commit 9d06c66

File tree

14 files changed

+317
-26
lines changed

14 files changed

+317
-26
lines changed

src/MatrixClientPeg.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
437437
// These are always installed regardless of the labs flag so that cross-signing features
438438
// can toggle on without reloading and also be accessed immediately after login.
439439
cryptoCallbacks: { ...crossSigningCallbacks },
440+
// We need the ability to encrypt/decrypt state events even if the lab is off, since rooms
441+
// with state event encryption still need to function properly.
442+
enableEncryptedStateEvents: true,
440443
roomNameGenerator: (_: string, state: RoomNameState) => {
441444
switch (state.type) {
442445
case RoomNameType.Generated:

src/components/views/dialogs/CreateRoomDialog.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface IProps {
3434
defaultName?: string;
3535
parentSpace?: Room;
3636
defaultEncrypted?: boolean;
37+
defaultStateEncrypted?: boolean;
3738
onFinished(proceed?: false): void;
3839
onFinished(proceed: true, opts: IOpts): void;
3940
}
@@ -52,6 +53,10 @@ interface IState {
5253
* Indicates whether end-to-end encryption is enabled for the room.
5354
*/
5455
isEncrypted: boolean;
56+
/**
57+
* Indicates whether end-to-end state encryption is enabled for this room.
58+
*/
59+
isStateEncrypted: boolean;
5560
/**
5661
* The room name.
5762
*/
@@ -111,6 +116,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
111116
this.state = {
112117
isPublicKnockRoom: defaultPublic || false,
113118
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli),
119+
isStateEncrypted: this.props.defaultStateEncrypted ?? false,
114120
joinRule,
115121
name: this.props.defaultName || "",
116122
topic: "",
@@ -136,6 +142,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
136142
createOpts.room_alias_name = alias.substring(1, alias.indexOf(":"));
137143
} else {
138144
opts.encryption = this.state.isEncrypted;
145+
opts.stateEncryption = this.state.isStateEncrypted;
139146
}
140147

141148
if (this.state.topic) {
@@ -230,6 +237,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
230237
this.setState({ isEncrypted });
231238
};
232239

240+
private onStateEncryptedChange = (isStateEncrypted: boolean): void => {
241+
this.setState({ isStateEncrypted });
242+
};
243+
233244
private onAliasChange = (alias: string): void => {
234245
this.setState({ alias });
235246
};
@@ -373,6 +384,31 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
373384
);
374385
}
375386

387+
let e2eeStateSection: JSX.Element | undefined;
388+
if (
389+
SettingsStore.getValue("feature_msc3414_encrypted_state_events", null, false) &&
390+
this.state.joinRule !== JoinRule.Public
391+
) {
392+
let microcopy: string;
393+
if (!this.state.canChangeEncryption) {
394+
microcopy = _t("create_room|encryption_forced");
395+
} else {
396+
microcopy = _t("create_room|state_encrypted_warning");
397+
}
398+
e2eeStateSection = (
399+
<React.Fragment>
400+
<LabelledToggleSwitch
401+
label={_t("create_room|state_encryption_label")}
402+
onChange={this.onStateEncryptedChange}
403+
value={this.state.isStateEncrypted}
404+
className="mx_CreateRoomDialog_e2eSwitch" // for end-to-end tests
405+
disabled={!this.state.canChangeEncryption}
406+
/>
407+
<p>{microcopy}</p>
408+
</React.Fragment>
409+
);
410+
}
411+
376412
let federateLabel = _t("create_room|unfederated_label_default_off");
377413
if (SdkConfig.get().default_federate === false) {
378414
// 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<IProps, IState> {
433469
{publicPrivateLabel}
434470
{visibilitySection}
435471
{e2eeSection}
472+
{e2eeStateSection}
436473
{aliasField}
437474
{this.advancedSettingsEnabled && (
438475
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">

src/components/views/messages/EncryptionEvent.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,20 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => {
4747
subtitle = _t("timeline|m.room.encryption|enabled_dm", { displayName });
4848
} else if (room && isLocalRoom(room)) {
4949
subtitle = _t("timeline|m.room.encryption|enabled_local");
50+
} else if (content["io.element.msc3414.encrypt_state_events"]) {
51+
subtitle = _t("timeline|m.room.encryption|state_enabled");
5052
} else {
5153
subtitle = _t("timeline|m.room.encryption|enabled");
5254
}
5355

5456
return (
5557
<EventTileBubble
5658
className="mx_cryptoEvent mx_cryptoEvent_icon"
57-
title={_t("common|encryption_enabled")}
59+
title={
60+
content["io.element.msc3414.encrypt_state_events"]
61+
? _t("common|state_encryption_enabled")
62+
: _t("common|encryption_enabled")
63+
}
5864
subtitle={subtitle}
5965
timestamp={timestamp}
6066
/>

src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface IState {
5555
history: HistoryVisibility;
5656
hasAliases: boolean;
5757
encrypted: boolean | null;
58+
stateEncrypted: boolean | null;
5859
showAdvancedSection: boolean;
5960
}
6061

@@ -80,6 +81,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
8081
),
8182
hasAliases: false, // async loaded in componentDidMount
8283
encrypted: null, // async loaded in componentDidMount
84+
stateEncrypted: null, // async loaded in componentDidMount
8385
showAdvancedSection: false,
8486
};
8587
}
@@ -90,6 +92,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
9092
this.setState({
9193
hasAliases: await this.hasAliases(),
9294
encrypted: Boolean(await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)),
95+
stateEncrypted: Boolean(
96+
await this.context.getCrypto()?.isStateEncryptionEnabledInRoom(this.props.room.roomId),
97+
),
9398
});
9499
}
95100

src/createRoom.ts

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ import {
2020
Preset,
2121
RestrictedAllowType,
2222
Visibility,
23+
Direction,
24+
RoomStateEvent,
25+
type RoomState,
2326
} from "matrix-js-sdk/src/matrix";
2427
import { logger } from "matrix-js-sdk/src/logger";
28+
import { type RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
2529

2630
import Modal, { type IHandle } from "./Modal";
2731
import { _t, UserFriendlyError } from "./languageHandler";
@@ -65,6 +69,7 @@ export interface IOpts {
6569
spinner?: boolean;
6670
guestAccess?: boolean;
6771
encryption?: boolean;
72+
stateEncryption?: boolean;
6873
inlineErrors?: boolean;
6974
andView?: boolean;
7075
avatar?: File | string; // will upload if given file, else mxcUrl is needed
@@ -112,6 +117,7 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
112117
if (opts.spinner === undefined) opts.spinner = true;
113118
if (opts.guestAccess === undefined) opts.guestAccess = true;
114119
if (opts.encryption === undefined) opts.encryption = false;
120+
if (opts.stateEncryption === undefined) opts.stateEncryption = false;
115121

116122
if (client.isGuest()) {
117123
dis.dispatch({ action: "require_registration" });
@@ -221,12 +227,16 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
221227
}
222228

223229
if (opts.encryption) {
230+
const content: RoomEncryptionEventContent = {
231+
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
232+
};
233+
if (opts.stateEncryption) {
234+
content["io.element.msc3414.encrypt_state_events"] = true;
235+
}
224236
createOpts.initial_state.push({
225237
type: "m.room.encryption",
226238
state_key: "",
227-
content: {
228-
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
229-
},
239+
content,
230240
});
231241
}
232242

@@ -263,24 +273,28 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
263273
});
264274
}
265275

266-
if (opts.name) {
267-
createOpts.name = opts.name;
268-
}
269-
270-
if (opts.topic) {
271-
createOpts.topic = opts.topic;
272-
}
276+
// If we are not encrypting state, copy name, topic, avatar over to
277+
// createOpts so we pass them in when we call Client.createRoom().
278+
if (!opts.stateEncryption) {
279+
if (opts.name) {
280+
createOpts.name = opts.name;
281+
}
273282

274-
if (opts.avatar) {
275-
let url = opts.avatar;
276-
if (opts.avatar instanceof File) {
277-
({ content_uri: url } = await client.uploadContent(opts.avatar));
283+
if (opts.topic) {
284+
createOpts.topic = opts.topic;
278285
}
279286

280-
createOpts.initial_state.push({
281-
type: EventType.RoomAvatar,
282-
content: { url },
283-
});
287+
if (opts.avatar) {
288+
let url = opts.avatar;
289+
if (opts.avatar instanceof File) {
290+
({ content_uri: url } = await client.uploadContent(opts.avatar));
291+
}
292+
293+
createOpts.initial_state.push({
294+
type: EventType.RoomAvatar,
295+
content: { url },
296+
});
297+
}
284298
}
285299

286300
if (opts.historyVisibility) {
@@ -340,6 +354,13 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
340354

341355
if (opts.dmUserId) await Rooms.setDMRoom(client, roomId, opts.dmUserId);
342356
})
357+
.then(async () => {
358+
// We need to set up initial state manually if state encryption is enabled, since it needs
359+
// to be encrypted.
360+
if (opts.encryption && opts.stateEncryption) {
361+
await enableStateEventEncryption(client, await room, opts);
362+
}
363+
})
343364
.then(() => {
344365
if (opts.parentSpace) {
345366
return SpaceStore.instance.addRoomToSpace(
@@ -414,6 +435,49 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
414435
);
415436
}
416437

438+
async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise<void> {
439+
await new Promise<void>((resolve, reject) => {
440+
if (room.hasEncryptionStateEvent()) {
441+
return resolve();
442+
}
443+
444+
const roomState = room.getLiveTimeline().getState(Direction.Forward)!;
445+
446+
// Soft fail, since the room will still be functional if the initial state is not encrypted.
447+
const timeout = setTimeout(() => {
448+
logger.warn("Timed out while waiting for room to enable encryption");
449+
roomState.off(RoomStateEvent.Update, onRoomStateUpdate);
450+
resolve();
451+
}, 3000);
452+
453+
const onRoomStateUpdate = (state: RoomState): void => {
454+
if (state.getStateEvents(EventType.RoomEncryption, "")) {
455+
roomState.off(RoomStateEvent.Update, onRoomStateUpdate);
456+
clearTimeout(timeout);
457+
resolve();
458+
}
459+
};
460+
461+
roomState.on(RoomStateEvent.Update, onRoomStateUpdate);
462+
});
463+
464+
// Set room name
465+
if (opts.name) {
466+
await client.setRoomName(room.roomId, opts.name);
467+
}
468+
469+
// Set room avatar
470+
if (opts.avatar) {
471+
let url: string;
472+
if (opts.avatar instanceof File) {
473+
({ content_uri: url } = await client.uploadContent(opts.avatar));
474+
} else {
475+
url = opts.avatar;
476+
}
477+
await client.sendStateEvent(room.roomId, EventType.RoomAvatar, { url }, "");
478+
}
479+
}
480+
417481
/*
418482
* Ensure that for every user in a room, there is at least one device that we
419483
* can encrypt to.

src/i18n/strings/en_EN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@
578578
"someone": "Someone",
579579
"space": "Space",
580580
"spaces": "Spaces",
581+
"state_encryption_enabled": "Experimental state encryption enabled",
581582
"sticker": "Sticker",
582583
"stickerpack": "Stickerpack",
583584
"success": "Success",
@@ -684,6 +685,8 @@
684685
"join_rule_restricted_label": "Everyone in <SpaceName/> will be able to find and join this room.",
685686
"name_validation_required": "Please enter a name for the room",
686687
"room_visibility_label": "Room visibility",
688+
"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.",
689+
"state_encryption_label": "Encrypt state events",
687690
"title_private_room": "Create a private room",
688691
"title_public_room": "Create a public room",
689692
"title_video_room": "Create a video room",
@@ -1520,6 +1523,8 @@
15201523
"dynamic_room_predecessors": "Dynamic room predecessors",
15211524
"dynamic_room_predecessors_description": "Enable MSC3946 (to support late-arriving room archives)",
15221525
"element_call_video_rooms": "Element Call video rooms",
1526+
"encrypted_state_events": "Encrypted state events",
1527+
"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.",
15231528
"exclude_insecure_devices": "Exclude insecure devices when sending/receiving messages",
15241529
"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.",
15251530
"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. <a>Learn more</a>.",
@@ -3562,6 +3567,7 @@
35623567
"enabled_dm": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their profile picture.",
35633568
"enabled_local": "Messages in this chat will be end-to-end encrypted.",
35643569
"parameters_changed": "Some encryption parameters have been changed.",
3570+
"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.",
35653571
"unsupported": "The encryption used by this room isn't supported."
35663572
},
35673573
"m.room.guest_access": {

src/settings/Settings.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index
5050
import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts";
5151
import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts";
5252
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
53+
import EncryptedStateEventsController from "./controllers/EncryptedStateEventsController.ts";
5354

5455
export const defaultWatchManager = new WatchManager();
5556

@@ -228,6 +229,7 @@ export interface Settings {
228229
"feature_new_room_list": IFeature;
229230
"feature_ask_to_join": IFeature;
230231
"feature_notifications": IFeature;
232+
"feature_msc3414_encrypted_state_events": IFeature;
231233
// These are in the feature namespace but aren't actually features
232234
"feature_hidebold": IBaseSetting<boolean>;
233235

@@ -780,6 +782,17 @@ export const SETTINGS: Settings = {
780782
supportedLevelsAreOrdered: true,
781783
default: false,
782784
},
785+
"feature_msc3414_encrypted_state_events": {
786+
isFeature: true,
787+
labsGroup: LabGroup.Encryption,
788+
controller: new EncryptedStateEventsController(),
789+
displayName: _td("labs|encrypted_state_events"),
790+
description: _td("labs|encrypted_state_events_description"),
791+
supportedLevels: LEVELS_ROOM_SETTINGS,
792+
supportedLevelsAreOrdered: true,
793+
shouldWarn: true,
794+
default: false,
795+
},
783796
"useCompactLayout": {
784797
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
785798
displayName: _td("settings|preferences|compact_modern"),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import PlatformPeg from "../../PlatformPeg";
9+
import { SettingLevel } from "../SettingLevel";
10+
import SettingsStore from "../SettingsStore";
11+
import SettingController from "./SettingController";
12+
13+
export default class EncryptedStateEventsController extends SettingController {
14+
public onChange(level: SettingLevel, roomId: string | null, newValue: boolean): void {
15+
SettingsStore.setValue("feature_share_history_on_invite", null, SettingLevel.CONFIG, newValue);
16+
PlatformPeg.get()?.reload();
17+
}
18+
}

0 commit comments

Comments
 (0)