Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/MatrixClientPeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions src/components/views/dialogs/CreateRoomDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface IProps {
defaultName?: string;
parentSpace?: Room;
defaultEncrypted?: boolean;
defaultStateEncrypted?: boolean;
onFinished(proceed?: false): void;
onFinished(proceed: true, opts: IOpts): void;
}
Expand All @@ -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.
*/
Expand Down Expand Up @@ -111,6 +116,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.state = {
isPublicKnockRoom: defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli),
isStateEncrypted: this.props.defaultStateEncrypted ?? false,
joinRule,
name: this.props.defaultName || "",
topic: "",
Expand All @@ -136,6 +142,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
createOpts.room_alias_name = alias.substring(1, alias.indexOf(":"));
} else {
opts.encryption = this.state.isEncrypted;
opts.stateEncryption = this.state.isStateEncrypted;
}

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

private onStateEncryptedChange = (isStateEncrypted: boolean): void => {
this.setState({ isStateEncrypted });
};

private onAliasChange = (alias: string): void => {
this.setState({ alias });
};
Expand Down Expand Up @@ -373,6 +384,31 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}

let e2eeStateSection: JSX.Element | undefined;
if (
SettingsStore.getValue("feature_msc3414_encrypted_state_events", null, false) &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we replace 3414 with 4362 everywhere?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, no because MSC4362 says to use prefix io.element.msc3414.encrypt_state_events

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we could rename the feature flag, though that might get confusing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do kinda think that we should rename the feature flag, and change the comments in the rust-sdk. We're implementing the proposals of MSC4362, not MSC3414.

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 = (
<React.Fragment>
<LabelledToggleSwitch
label={_t("create_room|state_encryption_label")}
onChange={this.onStateEncryptedChange}
value={this.state.isStateEncrypted}
className="mx_CreateRoomDialog_e2eSwitch" // for end-to-end tests
disabled={!this.state.canChangeEncryption}
/>
<p>{microcopy}</p>
</React.Fragment>
);
}

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
Expand Down Expand Up @@ -433,6 +469,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
{publicPrivateLabel}
{visibilitySection}
{e2eeSection}
{e2eeStateSection}
{aliasField}
{this.advancedSettingsEnabled && (
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
Expand Down
8 changes: 7 additions & 1 deletion src/components/views/messages/EncryptionEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,20 @@ 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");
}

return (
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("common|encryption_enabled")}
title={
content["io.element.msc3414.encrypt_state_events"]
? _t("common|state_encryption_enabled")
: _t("common|encryption_enabled")
}
subtitle={subtitle}
timestamp={timestamp}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface IState {
history: HistoryVisibility;
hasAliases: boolean;
encrypted: boolean | null;
stateEncrypted: boolean | null;
showAdvancedSection: boolean;
}

Expand All @@ -80,6 +81,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
),
hasAliases: false, // async loaded in componentDidMount
encrypted: null, // async loaded in componentDidMount
stateEncrypted: null, // async loaded in componentDidMount
showAdvancedSection: false,
};
}
Expand All @@ -90,6 +92,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
this.setState({
hasAliases: await this.hasAliases(),
encrypted: Boolean(await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)),
stateEncrypted: Boolean(
await this.context.getCrypto()?.isStateEncryptionEnabledInRoom(this.props.room.roomId),
),
});
}

Expand Down
100 changes: 82 additions & 18 deletions src/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import {
Preset,
RestrictedAllowType,
Visibility,
Direction,
RoomStateEvent,
type RoomState,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type RoomEncryptionEventContent } from "matrix-js-sdk/src/types";

import Modal, { type IHandle } from "./Modal";
import { _t, UserFriendlyError } from "./languageHandler";
Expand Down Expand Up @@ -65,6 +69,7 @@ export interface IOpts {
spinner?: boolean;
guestAccess?: boolean;
encryption?: boolean;
stateEncryption?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new public field needs doc-comment, please

inlineErrors?: boolean;
andView?: boolean;
avatar?: File | string; // will upload if given file, else mxcUrl is needed
Expand Down Expand Up @@ -112,6 +117,7 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true;
if (opts.encryption === undefined) opts.encryption = false;
if (opts.stateEncryption === undefined) opts.stateEncryption = false;

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

if (opts.encryption) {
const content: RoomEncryptionEventContent = {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
};
if (opts.stateEncryption) {
content["io.element.msc3414.encrypt_state_events"] = true;
}
createOpts.initial_state.push({
type: "m.room.encryption",
state_key: "",
content: {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
},
content,
});
}

Expand Down Expand Up @@ -263,24 +273,28 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
});
}

if (opts.name) {
createOpts.name = opts.name;
}

if (opts.topic) {
createOpts.topic = opts.topic;
}
// If we are not encrypting state, copy name, topic, avatar over to
// createOpts so we pass them in when we call Client.createRoom().
if (!opts.stateEncryption) {
if (opts.name) {
createOpts.name = opts.name;
}

if (opts.avatar) {
let url = opts.avatar;
if (opts.avatar instanceof File) {
({ content_uri: url } = await client.uploadContent(opts.avatar));
if (opts.topic) {
createOpts.topic = opts.topic;
}

createOpts.initial_state.push({
type: EventType.RoomAvatar,
content: { url },
});
if (opts.avatar) {
let url = opts.avatar;
if (opts.avatar instanceof File) {
({ content_uri: url } = await client.uploadContent(opts.avatar));
}

createOpts.initial_state.push({
type: EventType.RoomAvatar,
content: { url },
});
}
}

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

if (opts.dmUserId) await Rooms.setDMRoom(client, roomId, opts.dmUserId);
})
.then(async () => {
// 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(
Expand Down Expand Up @@ -414,6 +435,49 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
);
}

async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise<void> {
await new Promise<void>((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);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to do topic here too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and we need a test for this)


// 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.
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@
"someone": "Someone",
"space": "Space",
"spaces": "Spaces",
"state_encryption_enabled": "Experimental state encryption enabled",
"sticker": "Sticker",
"stickerpack": "Stickerpack",
"success": "Success",
Expand Down Expand Up @@ -684,6 +685,8 @@
"join_rule_restricted_label": "Everyone in <SpaceName/> 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",
Expand Down Expand Up @@ -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. <a>Learn more</a>.",
Expand Down Expand Up @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<boolean>;

Expand Down Expand Up @@ -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"),
Expand Down
18 changes: 18 additions & 0 deletions src/settings/controllers/EncryptedStateEventsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
Copyright 2024 New Vector Ltd.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to update to reflect company name change

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();
}
}
Loading
Loading