Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8c73c33
feat: Implemenet lab for encrypted state events (MSC3414)
kaylendog Jul 31, 2025
7d8f991
fix: Remove addition of optional encrypt_state_events field.
kaylendog Sep 24, 2025
d86bea5
fix: Only include `encrypt_state_events` if we set it to true.
kaylendog Sep 24, 2025
e7104b7
feat: Add `stateEncryption` flag to `SecurityRoomSettingsTab`.
kaylendog Sep 24, 2025
03d43ce
tests: Fixup tests for state event encryption lab.
kaylendog Sep 24, 2025
8a08134
fix: Run `yarn i18n` to (hopefully) correct translations.
kaylendog Sep 24, 2025
d51ba4b
Merge branch 'element-hq:develop' into kaylendog/msc3414
kaylendog Sep 25, 2025
d053a20
Merge branch 'develop' into kaylendog/msc3414
kaylendog Sep 25, 2025
f568949
feat: Enable encrypted state events JS client flag by default.
kaylendog Sep 25, 2025
a2ff003
Merge branch 'develop' into kaylendog/msc3414
kaylendog Sep 25, 2025
7a9d462
tests: Add `createRoom` tests for encryption and state event encryption.
kaylendog Sep 25, 2025
6b3f273
tests: Add test for state-encrypted EncryptionEvent.
kaylendog Sep 25, 2025
df86a11
feat: Encrypt room name in `createRoom` if state encryption enabled.
kaylendog Sep 29, 2025
d7092fb
Merge branch 'develop' into kaylendog/msc3414
kaylendog Sep 29, 2025
da30063
fix: Erase correct options variable.
kaylendog Sep 29, 2025
b691621
fix: Clone createOpts rather than construct reference.
kaylendog Sep 29, 2025
ebd42c1
tests: Ensure room name is not included in `initial_state`.
kaylendog Sep 29, 2025
bed2888
fix: De-async `onChange` method in encrypted state settings controller.
kaylendog Sep 29, 2025
fe4c48f
Merge branch 'develop' into kaylendog/msc3414
kaylendog Sep 29, 2025
d11cb24
feat: Re-introduce wait on room encryption state.
kaylendog Sep 30, 2025
0808466
fix: Linting error on import.
kaylendog Sep 30, 2025
0fc92d4
fix: Set shared history properly, spelling issues in translations.
kaylendog Oct 8, 2025
bb874c4
chore: Remove redundant if statement.
kaylendog Oct 8, 2025
d538135
fix: Simplify E2EE state dialog caption logic.
kaylendog Oct 8, 2025
10f34e1
feat: Refactor state encryption logic to helper function.
kaylendog Oct 8, 2025
330ec69
Merge remote-tracking branch 'upstream/develop' into kaylendog/msc3414
kaylendog Oct 9, 2025
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
43 changes: 43 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,37 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
);
}

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())) {
if (this.state.canChangeEncryption) {
microcopy = isVideoRoom
? _t("create_room|encrypted_video_room_warning")
: _t("create_room|state_encrypted_warning");
} else {
microcopy = _t("create_room|encryption_forced");
}
} else {
microcopy = _t("settings|security|e2ee_default_disabled_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 +475,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
9 changes: 8 additions & 1 deletion src/components/views/messages/EncryptionEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<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
13 changes: 10 additions & 3 deletions src/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Visibility,
} 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 @@ -53,6 +54,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 @@ -100,6 +102,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 @@ -209,12 +212,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
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 @@ -685,6 +686,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 @@ -1494,6 +1497,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_decsription": "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 @@ -3526,6 +3531,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": {
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 @@ -221,6 +222,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 @@ -783,6 +785,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_decsription"),
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.

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 async onChange(): Promise<void> {
SettingsStore.setValue("feature_share_history_on_invite", null, SettingLevel.CONFIG, true);
PlatformPeg.get()?.reload();
}
}
1 change: 1 addition & 0 deletions test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ describe("<CreateRoomDialog />", () => {
name: roomName,
},
encryption: true,
stateEncryption: false,
parentSpace: undefined,
roomType: undefined,
});
Expand Down Expand Up @@ -263,6 +264,7 @@ describe("<CreateRoomDialog />", () => {
visibility: Visibility.Private,
},
encryption: true,
stateEncryption: false,
joinRule: JoinRule.Knock,
parentSpace: undefined,
roomType: undefined,
Expand All @@ -281,6 +283,7 @@ describe("<CreateRoomDialog />", () => {
visibility: Visibility.Public,
},
encryption: true,
stateEncryption: false,
joinRule: JoinRule.Knock,
parentSpace: undefined,
roomType: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,34 @@ exports[`<CreateRoomDialog /> for a private room should render not the advanced
<p>
You can't disable this later. Bridges & most bots won't work yet.
</p>
<div
class="mx_SettingsFlag mx_CreateRoomDialog_e2eSwitch"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch_«r55»"
>
Encrypt state events
</div>
</span>
<div
aria-checked="false"
aria-disabled="false"
aria-labelledby="mx_LabelledToggleSwitch_«r55»"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<p>
Enables experimental support for encrypting state events, which hides metadata such as room names and topics from being visible to the server.
</p>
</div>
</form>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ describe("EncryptionEvent", () => {
);
});

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({
Expand Down
Loading
Loading