Provide a labs flag for encrypted state events (MSC3414) (#31513)
Signed-off-by: Skye Elliot <actuallyori@gmail.com> Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
@@ -437,6 +437,7 @@ 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 },
|
||||
enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"),
|
||||
roomNameGenerator: (_: string, state: RoomNameState) => {
|
||||
switch (state.type) {
|
||||
case RoomNameType.Generated:
|
||||
|
||||
@@ -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,11 @@ 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.
|
||||
* See MSC4362. Available if feature_msc4362_encrypted_state_events is enabled.
|
||||
*/
|
||||
isStateEncrypted: boolean;
|
||||
/**
|
||||
* The room name.
|
||||
*/
|
||||
@@ -117,6 +123,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: "",
|
||||
@@ -141,7 +148,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
const { alias } = this.state;
|
||||
createOpts.room_alias_name = alias.substring(1, alias.indexOf(":"));
|
||||
} else {
|
||||
const encryptedStateFeature = SettingsStore.getValue("feature_msc4362_encrypted_state_events", null, false);
|
||||
|
||||
opts.encryption = this.state.isEncrypted;
|
||||
opts.stateEncryption = encryptedStateFeature && this.state.isStateEncrypted;
|
||||
}
|
||||
|
||||
if (this.state.topic) {
|
||||
@@ -236,6 +246,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
this.setState({ isEncrypted: evt.target.checked });
|
||||
};
|
||||
|
||||
private onStateEncryptedChange: ChangeEventHandler<HTMLInputElement> = (evt): void => {
|
||||
this.setState({ isStateEncrypted: evt.target.checked });
|
||||
};
|
||||
|
||||
private onAliasChange = (alias: string): void => {
|
||||
this.setState({ alias });
|
||||
};
|
||||
@@ -378,6 +392,29 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<SettingsToggleInput
|
||||
name="state-encryption-toggle"
|
||||
label={_t("create_room|state_encryption_label")}
|
||||
onChange={this.onStateEncryptedChange}
|
||||
checked={this.state.isStateEncrypted}
|
||||
disabled={!this.state.canChangeEncryption}
|
||||
helpMessage={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
|
||||
@@ -441,6 +478,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
|
||||
{visibilitySection}
|
||||
{e2eeSection}
|
||||
{e2eeStateSection}
|
||||
{aliasField}
|
||||
{this.advancedSettingsEnabled && (
|
||||
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
|
||||
|
||||
@@ -40,6 +40,9 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => {
|
||||
let subtitle: string;
|
||||
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
const room = cli?.getRoom(roomId);
|
||||
|
||||
const stateEncrypted = content["io.element.msc4362.encrypt_state_events"] && cli.enableEncryptedStateEvents;
|
||||
|
||||
if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) {
|
||||
subtitle = _t("timeline|m.room.encryption|parameters_changed");
|
||||
} else if (dmPartner) {
|
||||
@@ -47,6 +50,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 (stateEncrypted) {
|
||||
subtitle = _t("timeline|m.room.encryption|state_enabled");
|
||||
} else {
|
||||
subtitle = _t("timeline|m.room.encryption|enabled");
|
||||
}
|
||||
@@ -54,7 +59,7 @@ const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => {
|
||||
return (
|
||||
<EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon"
|
||||
title={_t("common|encryption_enabled")}
|
||||
title={stateEncrypted ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled")}
|
||||
subtitle={subtitle}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
|
||||
@@ -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<IProps, ISt
|
||||
),
|
||||
hasAliases: false, // async loaded in componentDidMount
|
||||
encrypted: null, // async loaded in componentDidMount
|
||||
stateEncrypted: null, // async loaded in componentDidMount
|
||||
showAdvancedSection: false,
|
||||
};
|
||||
}
|
||||
@@ -89,6 +91,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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,6 +485,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
||||
const client = this.context;
|
||||
const room = this.props.room;
|
||||
const isEncrypted = this.state.encrypted;
|
||||
const isStateEncrypted = this.state.stateEncrypted;
|
||||
const isEncryptionLoading = isEncrypted === null;
|
||||
const hasEncryptionPermission = room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, client);
|
||||
const isEncryptionForceDisabled = shouldForceDisableEncryption(client);
|
||||
@@ -533,6 +539,14 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
||||
{isEncryptionForceDisabled && !isEncrypted && (
|
||||
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
|
||||
)}
|
||||
{isStateEncrypted && (
|
||||
<SettingsToggleInput
|
||||
name="enable-state-encryption"
|
||||
checked={isStateEncrypted}
|
||||
label={_t("common|state_encryption_enabled")}
|
||||
disabled={true}
|
||||
/>
|
||||
)}
|
||||
{encryptionSettings}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,8 +21,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";
|
||||
@@ -44,6 +48,7 @@ import { doesRoomVersionSupport, PreferredRoomVersions } from "./utils/Preferred
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
|
||||
import { ElementCallMemberEventType } from "./call-types";
|
||||
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
|
||||
|
||||
// we define a number of interfaces which take their names from the js-sdk
|
||||
/* eslint-disable camelcase */
|
||||
@@ -66,6 +71,10 @@ export interface IOpts {
|
||||
spinner?: boolean;
|
||||
guestAccess?: boolean;
|
||||
encryption?: boolean;
|
||||
/**
|
||||
* Encrypt state events as per MSC4362
|
||||
*/
|
||||
stateEncryption?: boolean;
|
||||
inlineErrors?: boolean;
|
||||
andView?: boolean;
|
||||
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
|
||||
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" });
|
||||
@@ -207,12 +217,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.msc4362.encrypt_state_events"] = true;
|
||||
}
|
||||
createOpts.initial_state.push({
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
|
||||
},
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,24 +270,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 (opts.avatar) {
|
||||
let url = opts.avatar;
|
||||
if (opts.avatar instanceof File) {
|
||||
({ content_uri: url } = await client.uploadContent(opts.avatar));
|
||||
// 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;
|
||||
}
|
||||
|
||||
createOpts.initial_state.push({
|
||||
type: EventType.RoomAvatar,
|
||||
content: { url },
|
||||
});
|
||||
if (opts.topic) {
|
||||
createOpts.topic = opts.topic;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -330,6 +348,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);
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
if (modal) modal.close();
|
||||
})
|
||||
@@ -401,6 +426,73 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
|
||||
);
|
||||
}
|
||||
|
||||
async function enableStateEventEncryption(client: MatrixClient, room: Room, opts: IOpts): Promise<void> {
|
||||
// Don't send our state events until encryption is enabled. If this times
|
||||
// out after 30 seconds, we throw since we don't want to send the events
|
||||
// unencrypted.
|
||||
await waitForRoomEncryption(room, 30000);
|
||||
|
||||
// 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.
|
||||
*/
|
||||
export async function waitForRoomEncryption(room: Room, waitTimeMs: number): Promise<void> {
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a 30s timeout and return "timed_out" if we hit it
|
||||
const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers();
|
||||
const timeout = setTimeout(timeoutResolve, waitTimeMs, "timed_out");
|
||||
|
||||
// Listen for a RoomEncryption state update and return
|
||||
// "received_encryption_state" if we get it
|
||||
const roomState = room.getLiveTimeline().getState(Direction.Forward)!;
|
||||
const { promise: stateUpdatePromise, resolve: stateUpdateResolve } = Promise.withResolvers();
|
||||
const onRoomStateUpdate = (state: RoomState): void => {
|
||||
if (state.getStateEvents(EventType.RoomEncryption, "")) {
|
||||
stateUpdateResolve("received_encryption_state");
|
||||
}
|
||||
};
|
||||
roomState.on(RoomStateEvent.Update, onRoomStateUpdate);
|
||||
|
||||
// Wait for one of the above to happen
|
||||
const resolution = await Promise.race([timeoutPromise, stateUpdatePromise]);
|
||||
|
||||
// Clear the listener and the timeout
|
||||
roomState.off(RoomStateEvent.Update, onRoomStateUpdate);
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Fail if we hit the timeout
|
||||
if (resolution === "timed_out") {
|
||||
logger.warn("Timed out while waiting for room to enable encryption");
|
||||
throw new Error("Timed out while waiting for room to enable encryption");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Ensure that for every user in a room, there is at least one device that we
|
||||
* can encrypt to.
|
||||
|
||||
@@ -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 <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 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. <a>Learn more</a>.",
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<boolean>;
|
||||
|
||||
@@ -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_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
|
||||
supportedLevelsAreOrdered: true,
|
||||
shouldWarn: true,
|
||||
default: false,
|
||||
},
|
||||
"useCompactLayout": {
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
|
||||
displayName: _td("settings|preferences|compact_modern"),
|
||||
|
||||
Reference in New Issue
Block a user