Skip to content

Commit e2b046e

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

File tree

14 files changed

+347
-25
lines changed

14 files changed

+347
-25
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
@@ -33,13 +33,15 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
3333
import { privateShouldBeEncrypted } from "../../../utils/rooms";
3434
import SettingsStore from "../../../settings/SettingsStore";
3535
import { UIFeature } from "../../../settings/UIFeature";
36+
import ToggleSwitch from "../elements/ToggleSwitch";
3637

3738
interface IProps {
3839
type?: RoomType;
3940
defaultPublic?: boolean;
4041
defaultName?: string;
4142
parentSpace?: Room;
4243
defaultEncrypted?: boolean;
44+
defaultStateEncrypted?: boolean;
4345
onFinished(proceed?: false): void;
4446
onFinished(proceed: true, opts: IOpts): void;
4547
}
@@ -58,6 +60,10 @@ interface IState {
5860
* Indicates whether end-to-end encryption is enabled for the room.
5961
*/
6062
isEncrypted: boolean;
63+
/**
64+
* Indicates whether end-to-end state encryption is enabled for this room.
65+
*/
66+
isStateEncrypted: boolean;
6167
/**
6268
* The room name.
6369
*/
@@ -117,6 +123,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
117123
this.state = {
118124
isPublicKnockRoom: defaultPublic || false,
119125
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli),
126+
isStateEncrypted: this.props.defaultStateEncrypted ?? false,
120127
joinRule,
121128
name: this.props.defaultName || "",
122129
topic: "",
@@ -142,6 +149,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
142149
createOpts.room_alias_name = alias.substring(1, alias.indexOf(":"));
143150
} else {
144151
opts.encryption = this.state.isEncrypted;
152+
opts.stateEncryption = this.state.isStateEncrypted;
145153
}
146154

147155
if (this.state.topic) {
@@ -236,6 +244,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
236244
this.setState({ isEncrypted: evt.target.checked });
237245
};
238246

247+
private onStateEncryptedChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
248+
this.setState({ isStateEncrypted: evt.target.checked });
249+
};
250+
239251
private onAliasChange = (alias: string): void => {
240252
this.setState({ alias });
241253
};
@@ -378,6 +390,30 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
378390
);
379391
}
380392

393+
let e2eeStateSection: JSX.Element | undefined;
394+
if (
395+
SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false) &&
396+
this.state.joinRule !== JoinRule.Public
397+
) {
398+
let microcopy: string;
399+
if (!this.state.canChangeEncryption) {
400+
microcopy = _t("create_room|encryption_forced");
401+
} else {
402+
microcopy = _t("create_room|state_encrypted_warning");
403+
}
404+
e2eeStateSection = (
405+
<SettingsToggleInput
406+
name="state-encryption-toggle"
407+
label={_t("create_room|state_encryption_label")}
408+
onChange={this.onStateEncryptedChange}
409+
checked={this.state.isStateEncrypted}
410+
className="mx_CreateRoomDialog_e2eSwitch" // for end-to-end tests
411+
disabled={!this.state.canChangeEncryption}
412+
helpMessage={microcopy}
413+
/>
414+
);
415+
}
416+
381417
let federateLabel = _t("create_room|unfederated_label_default_off");
382418
if (SdkConfig.get().default_federate === false) {
383419
// We only change the label if the default setting is different to avoid jarring text changes to the
@@ -441,6 +477,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
441477

442478
{visibilitySection}
443479
{e2eeSection}
480+
{e2eeStateSection}
444481
{aliasField}
445482
{this.advancedSettingsEnabled && (
446483
<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
@@ -54,6 +54,7 @@ interface IState {
5454
history: HistoryVisibility;
5555
hasAliases: boolean;
5656
encrypted: boolean | null;
57+
stateEncrypted: boolean | null;
5758
showAdvancedSection: boolean;
5859
}
5960

@@ -79,6 +80,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
7980
),
8081
hasAliases: false, // async loaded in componentDidMount
8182
encrypted: null, // async loaded in componentDidMount
83+
stateEncrypted: null, // async loaded in componentDidMount
8284
showAdvancedSection: false,
8385
};
8486
}
@@ -89,6 +91,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
8991
this.setState({
9092
hasAliases: await this.hasAliases(),
9193
encrypted: Boolean(await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)),
94+
stateEncrypted: Boolean(
95+
await this.context.getCrypto()?.isStateEncryptionEnabledInRoom(this.props.room.roomId),
96+
),
9297
});
9398
}
9499

src/createRoom.ts

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import {
2121
Preset,
2222
RestrictedAllowType,
2323
Visibility,
24+
Direction,
25+
RoomStateEvent,
26+
type RoomState,
2427
} from "matrix-js-sdk/src/matrix";
2528
import { logger } from "matrix-js-sdk/src/logger";
29+
import { type RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
2630

2731
import Modal, { type IHandle } from "./Modal";
2832
import { _t, UserFriendlyError } from "./languageHandler";
@@ -44,6 +48,7 @@ import { doesRoomVersionSupport, PreferredRoomVersions } from "./utils/Preferred
4448
import SettingsStore from "./settings/SettingsStore";
4549
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
4650
import { ElementCallMemberEventType } from "./call-types";
51+
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
4752

4853
// we define a number of interfaces which take their names from the js-sdk
4954
/* eslint-disable camelcase */
@@ -66,6 +71,10 @@ export interface IOpts {
6671
spinner?: boolean;
6772
guestAccess?: boolean;
6873
encryption?: boolean;
74+
/**
75+
* Encrypt state events as per MSC4362
76+
*/
77+
stateEncryption?: boolean;
6978
inlineErrors?: boolean;
7079
andView?: boolean;
7180
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
113122
if (opts.spinner === undefined) opts.spinner = true;
114123
if (opts.guestAccess === undefined) opts.guestAccess = true;
115124
if (opts.encryption === undefined) opts.encryption = false;
125+
if (opts.stateEncryption === undefined) opts.stateEncryption = false;
116126

117127
if (client.isGuest()) {
118128
dis.dispatch({ action: "require_registration" });
@@ -207,12 +217,16 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
207217
}
208218

209219
if (opts.encryption) {
220+
const content: RoomEncryptionEventContent = {
221+
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
222+
};
223+
if (opts.stateEncryption) {
224+
content["io.element.msc3414.encrypt_state_events"] = true;
225+
}
210226
createOpts.initial_state.push({
211227
type: "m.room.encryption",
212228
state_key: "",
213-
content: {
214-
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
215-
},
229+
content,
216230
});
217231
}
218232

@@ -256,24 +270,28 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
256270
});
257271
}
258272

259-
if (opts.name) {
260-
createOpts.name = opts.name;
261-
}
262-
263-
if (opts.topic) {
264-
createOpts.topic = opts.topic;
265-
}
273+
// If we are not encrypting state, copy name, topic, avatar over to
274+
// createOpts so we pass them in when we call Client.createRoom().
275+
if (!opts.stateEncryption) {
276+
if (opts.name) {
277+
createOpts.name = opts.name;
278+
}
266279

267-
if (opts.avatar) {
268-
let url = opts.avatar;
269-
if (opts.avatar instanceof File) {
270-
({ content_uri: url } = await client.uploadContent(opts.avatar));
280+
if (opts.topic) {
281+
createOpts.topic = opts.topic;
271282
}
272283

273-
createOpts.initial_state.push({
274-
type: EventType.RoomAvatar,
275-
content: { url },
276-
});
284+
if (opts.avatar) {
285+
let url = opts.avatar;
286+
if (opts.avatar instanceof File) {
287+
({ content_uri: url } = await client.uploadContent(opts.avatar));
288+
}
289+
290+
createOpts.initial_state.push({
291+
type: EventType.RoomAvatar,
292+
content: { url },
293+
});
294+
}
277295
}
278296

279297
if (opts.historyVisibility) {
@@ -330,6 +348,13 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
330348

331349
if (opts.dmUserId) await Rooms.setDMRoom(client, roomId, opts.dmUserId);
332350
})
351+
.then(async () => {
352+
// We need to set up initial state manually if state encryption is enabled, since it needs
353+
// to be encrypted.
354+
if (opts.encryption && opts.stateEncryption) {
355+
await enableStateEventEncryption(client, await room, opts);
356+
}
357+
})
333358
.finally(function () {
334359
if (modal) modal.close();
335360
})
@@ -401,6 +426,64 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
401426
);
402427
}
403428

429+
async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise<void> {
430+
// Don't send our state events until encryption is enabled.
431+
// If this times out, we throw since we don't want to send the events unencrypted.
432+
await waitForRoomEncryption(room);
433+
434+
// Set room name
435+
if (opts.name) {
436+
await client.setRoomName(room.roomId, opts.name);
437+
}
438+
439+
// Set room topic
440+
if (opts.topic) {
441+
const htmlTopic = htmlSerializeFromMdIfNeeded(opts.topic, { forceHTML: false });
442+
await client.setRoomTopic(room.roomId, opts.topic, htmlTopic);
443+
}
444+
445+
// Set room avatar
446+
if (opts.avatar) {
447+
let url: string;
448+
if (opts.avatar instanceof File) {
449+
({ content_uri: url } = await client.uploadContent(opts.avatar));
450+
} else {
451+
url = opts.avatar;
452+
}
453+
await client.sendStateEvent(room.roomId, EventType.RoomAvatar, { url }, "");
454+
}
455+
}
456+
457+
/**
458+
* Wait until the supplied room has an `m.room.encryption` event, or time out
459+
* after 3 seconds.
460+
*/
461+
async function waitForRoomEncryption(room: Room): Promise<void> {
462+
await new Promise<void>((resolve, reject) => {
463+
if (room.hasEncryptionStateEvent()) {
464+
return resolve();
465+
}
466+
467+
const roomState = room.getLiveTimeline().getState(Direction.Forward)!;
468+
469+
const timeout = setTimeout(() => {
470+
logger.warn("Timed out while waiting for room to enable encryption");
471+
roomState.off(RoomStateEvent.Update, onRoomStateUpdate);
472+
reject("Timed out while waiting for room to enable encryption");
473+
}, 30000);
474+
475+
const onRoomStateUpdate = (state: RoomState): void => {
476+
if (state.getStateEvents(EventType.RoomEncryption, "")) {
477+
roomState.off(RoomStateEvent.Update, onRoomStateUpdate);
478+
clearTimeout(timeout);
479+
resolve();
480+
}
481+
};
482+
483+
roomState.on(RoomStateEvent.Update, onRoomStateUpdate);
484+
});
485+
}
486+
404487
/*
405488
* Ensure that for every user in a room, there is at least one device that we
406489
* can encrypt to.

src/i18n/strings/en_EN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@
579579
"someone": "Someone",
580580
"space": "Space",
581581
"spaces": "Spaces",
582+
"state_encryption_enabled": "Experimental state encryption enabled",
582583
"sticker": "Sticker",
583584
"stickerpack": "Stickerpack",
584585
"success": "Success",
@@ -686,6 +687,8 @@
686687
"join_rule_restricted_label": "Everyone in <SpaceName/> will be able to find and join this room.",
687688
"name_validation_required": "Please enter a name for the room",
688689
"room_visibility_label": "Room visibility",
690+
"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.",
691+
"state_encryption_label": "Encrypt state events",
689692
"title_private_room": "Create a private room",
690693
"title_public_room": "Create a public room",
691694
"title_video_room": "Create a video room",
@@ -1522,6 +1525,8 @@
15221525
"dynamic_room_predecessors": "Dynamic room predecessors",
15231526
"dynamic_room_predecessors_description": "Enable MSC3946 (to support late-arriving room archives)",
15241527
"element_call_video_rooms": "Element Call video rooms",
1528+
"encrypted_state_events": "Encrypted state events (MSC4362)",
1529+
"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.",
15251530
"exclude_insecure_devices": "Exclude insecure devices when sending/receiving messages",
15261531
"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.",
15271532
"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>.",
@@ -3579,6 +3584,7 @@
35793584
"enabled_dm": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their profile picture.",
35803585
"enabled_local": "Messages in this chat will be end-to-end encrypted.",
35813586
"parameters_changed": "Some encryption parameters have been changed.",
3587+
"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.",
35823588
"unsupported": "The encryption used by this room isn't supported."
35833589
},
35843590
"m.room.guest_access": {

src/settings/Settings.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export interface Settings {
229229
"feature_new_room_list": IFeature;
230230
"feature_ask_to_join": IFeature;
231231
"feature_notifications": IFeature;
232+
"feature_msc4362_encrypted_state_events": IFeature;
232233
// These are in the feature namespace but aren't actually features
233234
"feature_hidebold": IBaseSetting<boolean>;
234235

@@ -788,6 +789,16 @@ export const SETTINGS: Settings = {
788789
supportedLevelsAreOrdered: true,
789790
default: false,
790791
},
792+
"feature_msc4362_encrypted_state_events": {
793+
isFeature: true,
794+
labsGroup: LabGroup.Encryption,
795+
displayName: _td("labs|encrypted_state_events"),
796+
description: _td("labs|encrypted_state_events_description"),
797+
supportedLevels: LEVELS_ROOM_SETTINGS,
798+
supportedLevelsAreOrdered: true,
799+
shouldWarn: true,
800+
default: false,
801+
},
791802
"useCompactLayout": {
792803
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
793804
displayName: _td("settings|preferences|compact_modern"),

0 commit comments

Comments
 (0)