Provide a labs flag for encrypted state events (MSC3414) (#31513)
Some checks failed
Build / Build on ${{ matrix.image }} (macos-14, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Waiting to run
Build / Build on ${{ matrix.image }} (ubuntu-24.04, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Waiting to run
Build / Build on ${{ matrix.image }} (windows-2022, ${{ github.event_name == 'push' && github.ref_name == 'develop' }}, ${{ github.event_name == 'pull_request' }}) (push) Waiting to run
Build and Deploy develop / Build & Deploy develop.element.io (push) Waiting to run
Deploy documentation / GitHub Pages (push) Waiting to run
Deploy documentation / deploy (push) Blocked by required conditions
Localazy Upload / upload (push) Waiting to run
Shared Component Visual Tests / Run Visual Tests (push) Waiting to run
Static Analysis / Typescript Syntax Check (push) Waiting to run
Static Analysis / i18n Check (push) Waiting to run
Static Analysis / Rethemendex Check (push) Waiting to run
Static Analysis / ESLint (push) Waiting to run
Static Analysis / Style Lint (push) Waiting to run
Static Analysis / Workflow Lint (push) Waiting to run
Static Analysis / Analyse Dead Code (push) Waiting to run
Localazy Download / download (push) Has been cancelled

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Andy Balaam 2025-12-18 14:45:52 +00:00 committed by GitHub
parent 6f0369e623
commit ff3f069122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 858 additions and 26 deletions

View File

@ -112,3 +112,25 @@ Enables knock feature for rooms. This allows users to ask to join a room.
## New room list (`feature_new_room_list`) [In Development]
Enable the new room list that is currently in development.
## Exclude insecure devices when sending/receiving messages (`feature_exclude_insecure_devices`)
Do not send or receive messages to/from devices that are not properly verified. Users with unverified devices will not
receive your messages at all on those devices, and if they send messages, you will not be able to read them, but you
will be aware that a message exists.
## Share encrypted history with new members (`feature_share_history_on_invite`) [In Development]
When inviting users to an encrypted room with shared history (i.e. a room with the "Who can read history?" setting set
to "Members only (since the point in time of selecting this option)"), send the keys for previous messages to the
invitee so they can read them.
Both the inviter and the invitee must set this labs flag, before the invitation is sent.
## Encrypted state events (MSC4362) (`feature_msc4362_encrypted_state_events`)
Encrypt most of the state events in the room, including the room name and topic.
WARNING: this means that users joining a room who do not have access to its history will not be able to see the name or
topic of the room, or any other room state information. It also means the room name and topic are not available before
joining a room.

View File

@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "playwright-core";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { UIFeature } from "../../../src/settings/UIFeature";
import { test, expect } from "../../element-web-test";
@ -110,4 +112,107 @@ test.describe("Create Room", () => {
await expect(header).toContainText(name);
});
});
test.describe("when the encrypted state labs flag is turned off", () => {
test.use({ labsFlags: [] });
test("creates a room without encrypted state", { tag: "@screenshot" }, async ({ page, user: _user }) => {
// When we start to create a room
await page.getByRole("button", { name: "New conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill(name);
// Then there is no Encrypt state events button
await expect(page.getByRole("checkbox", { name: "Encrypt state events" })).not.toBeVisible();
// And when we create the room
await page.getByRole("button", { name: "Create room" }).click();
// Then we created a normal encrypted room, without encrypted state
await expect(page.getByText("Encryption enabled")).toBeVisible();
await expect(page.getByText("State encryption enabled")).not.toBeVisible();
// And the room name state event is not encrypted
await viewSourceOnRoomNameEvent(page);
await expect(page.getByText("Original event source")).toBeVisible();
await expect(page.getByText("Decrypted event source")).not.toBeVisible();
});
});
test.describe("when the encrypted state labs flag is turned on", () => {
test.use({ labsFlags: ["feature_msc4362_encrypted_state_events"] });
test(
"creates a room with encrypted state if we check the box",
{ tag: "@screenshot" },
async ({ page, user: _user }) => {
// Given we check the Encrypted State checkbox
await page.getByRole("button", { name: "New conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked();
await page.getByRole("switch", { name: "Encrypt state events" }).click();
await expect(page.getByRole("switch", { name: "Encrypt state events" })).toBeChecked();
// When we create a room
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
// Then we created an encrypted state room
await expect(page.getByText("State encryption enabled")).toBeVisible();
// And it has the correct name
await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible();
// And the room name state event is encrypted
await viewSourceOnRoomNameEvent(page);
await expect(page.getByText("Decrypted event source")).toBeVisible();
},
);
test(
"creates a room without encrypted state if we don't check the box",
{ tag: "@screenshot" },
async ({ page, user: _user }) => {
// Given we did not check the Encrypted State checkbox
await page.getByRole("button", { name: "New conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await expect(page.getByRole("switch", { name: "Enable end-to-end encryption" })).toBeChecked();
// And it is off by default
await expect(page.getByRole("switch", { name: "Encrypt state events" })).not.toBeChecked();
// When we create a room
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
// Then we created a normal encrypted room, without encrypted state
await expect(page.getByText("Encryption enabled")).toBeVisible();
await expect(page.getByText("State encryption enabled")).not.toBeVisible();
// And it has the correct name
await expect(page.getByTestId("timeline").getByRole("heading", { name })).toBeVisible();
// And the room name state event is not encrypted
await viewSourceOnRoomNameEvent(page);
await expect(page.getByText("Original event source")).toBeVisible();
await expect(page.getByText("Decrypted event source")).not.toBeVisible();
},
);
});
});
async function viewSourceOnRoomNameEvent(page: Page) {
await page
.getByRole("listitem")
.filter({ hasText: "created and configured the room" })
.getByRole("button", { name: "expand" })
.click();
await page
.getByRole("listitem")
.filter({ hasText: "changed the room name to" })
.getByRole("button", { name: "Options" })
.click();
await page.getByRole("menuitem", { name: "View source" }).click();
}

View File

@ -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:

View File

@ -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">

View File

@ -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}
/>

View File

@ -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}
</>
)}

View File

@ -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.

View File

@ -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": {

View File

@ -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"),

View File

@ -17,7 +17,7 @@ import {
type IEvent,
type RoomMember,
type MatrixClient,
type RoomState,
RoomState,
EventType,
type IEventRelation,
type IUnsigned,
@ -31,6 +31,7 @@ import {
type OidcClientConfig,
type GroupCall,
HistoryVisibility,
type ICreateRoomOpts,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { normalize } from "matrix-js-sdk/src/utils";
@ -85,6 +86,7 @@ export function createTestClient(): MatrixClient {
const eventEmitter = new EventEmitter();
let txnId = 1;
let createdRoom: Room | undefined;
const client = {
getHomeserverUrl: jest.fn(),
@ -124,6 +126,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(),
@ -162,7 +165,14 @@ export function createTestClient(): MatrixClient {
}),
getPushActionsForEvent: jest.fn(),
getRoom: jest.fn().mockImplementation((roomId) => mkStubRoom(roomId, "My room", client)),
getRoom: jest.fn().mockImplementation((roomId) => {
// If the test called `createRoom`, return the mocked room it created.
if (createdRoom) {
return createdRoom;
} else {
return mkStubRoom(roomId, "My room", client);
}
}),
getRooms: jest.fn().mockReturnValue([]),
getVisibleRooms: jest.fn().mockReturnValue([]),
loginFlows: jest.fn(),
@ -201,6 +211,7 @@ export function createTestClient(): MatrixClient {
setAccountData: jest.fn(),
deleteAccountData: jest.fn(),
setRoomAccountData: jest.fn(),
setRoomName: jest.fn(),
setRoomTopic: jest.fn(),
setRoomReadMarkers: jest.fn().mockResolvedValue({}),
sendTyping: jest.fn().mockResolvedValue({}),
@ -213,7 +224,23 @@ export function createTestClient(): MatrixClient {
getRoomHierarchy: jest.fn().mockReturnValue({
rooms: [],
}),
createRoom: jest.fn().mockResolvedValue({ room_id: "!1:example.org" }),
createRoom: jest.fn(async (createOpts?: ICreateRoomOpts) => {
const initialState = createOpts?.initial_state?.map((event, i) =>
mkEvent({
...event,
room: "!1:example.org",
user: "@user:example.com",
event: true,
}),
);
createdRoom = mkStubRoom(
"!1:example.org",
"My room",
client,
initialState && mkRoomState("!1:example.org", initialState),
);
return { room_id: "!1:example.org" };
}),
setPowerLevel: jest.fn().mockResolvedValue(undefined),
pushRules: {},
decryptEventIfNeeded: () => Promise.resolve(),
@ -616,10 +643,11 @@ export function mkStubRoom(
roomId: string | null | undefined = null,
name: string | undefined,
client: MatrixClient | undefined,
state?: RoomState | undefined,
): Room {
const stubTimeline = {
getEvents: (): MatrixEvent[] => [],
getState: (): RoomState | undefined => undefined,
getState: (): RoomState | undefined => state,
} as unknown as EventTimeline;
return {
canInvite: jest.fn().mockReturnValue(false),
@ -701,6 +729,22 @@ export function mkStubRoom(
} as unknown as Room;
}
export function mkRoomState(
roomId: string = "!1:example.org",
stateEvents: MatrixEvent[] = [],
members: RoomMember[] = [],
): RoomState {
const roomState = new RoomState(roomId);
roomState.setStateEvents(stateEvents);
for (const member of members) {
roomState.members[member.userId] = member;
}
return roomState;
}
export function mkServerConfig(
hsUrl: string,
isUrl: string,

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, within } from "jest-matrix-react";
import { act, fireEvent, render, screen, within } from "jest-matrix-react";
import { type Room, JoinRule, MatrixError, Preset, Visibility } from "matrix-js-sdk/src/matrix";
import CreateRoomDialog from "../../../../../src/components/views/dialogs/CreateRoomDialog";
@ -247,6 +247,7 @@ describe("<CreateRoomDialog />", () => {
createOpts: {},
name: roomName,
encryption: true,
stateEncryption: false,
parentSpace: undefined,
roomType: undefined,
});
@ -260,6 +261,29 @@ describe("<CreateRoomDialog />", () => {
await flushPromises();
expect(asFragment()).toMatchSnapshot();
});
describe("when the state encryption labs flag is on", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_msc4362_encrypted_state_events",
);
});
it("should turn on state encryption when toggled", async () => {
// Given we have the create room dialog open
const { asFragment } = getComponent();
await flushPromises();
expect(asFragment()).toMatchSnapshot();
// When I click the Encrypt state events toggle
const toggle = screen.getByRole("switch", { name: "Encrypt state events" });
expect(toggle).not.toBeChecked();
act(() => toggle.click());
// Then it changes state
expect(toggle).toBeChecked();
});
});
});
describe("for a knock room", () => {
@ -308,6 +332,7 @@ describe("<CreateRoomDialog />", () => {
},
name: roomName,
encryption: true,
stateEncryption: false,
joinRule: JoinRule.Knock,
parentSpace: undefined,
roomType: undefined,
@ -326,6 +351,7 @@ describe("<CreateRoomDialog />", () => {
},
name: roomName,
encryption: true,
stateEncryption: false,
joinRule: JoinRule.Knock,
parentSpace: undefined,
roomType: undefined,

View File

@ -390,6 +390,273 @@ exports[`<CreateRoomDialog /> for a private room should render not the advanced
</span>
</div>
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
class="_input_udcm8_24"
id="_r_7n_"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="_r_7n_"
>
Encrypt state events
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_7p_"
>
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.
</span>
</div>
</div>
</form>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Create room
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;
exports[`<CreateRoomDialog /> for a private room when the state encryption labs flag is on should turn on state encryption when toggled 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateRoomDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Create a private room
</h1>
</div>
<div
class="mx_Dialog_content"
>
<form
class="_root_19upo_16"
>
<div
class="mx_Field mx_Field_input mx_CreateRoomDialog_name"
>
<input
id="mx_Field_29"
label="Name"
placeholder="Name"
type="text"
value=""
/>
<label
for="mx_Field_29"
>
Name
</label>
</div>
<div
class="mx_Field mx_Field_input mx_CreateRoomDialog_topic"
>
<input
id="mx_Field_30"
label="Topic (optional)"
placeholder="Topic (optional)"
type="text"
value=""
/>
<label
for="mx_Field_30"
>
Topic (optional)
</label>
</div>
<div>
<div
class="mx_Dropdown mx_JoinRuleDropdown mx_Dropdown_disabled"
>
<div
aria-describedby="mx_JoinRuleDropdown_value"
aria-disabled="true"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Room visibility"
aria-owns="mx_JoinRuleDropdown_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="mx_JoinRuleDropdown_value"
>
<div
class="mx_JoinRuleDropdown_invite"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
/>
</svg>
Private room (invite only)
</div>
</div>
<svg
class="mx_Dropdown_arrow"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
<p>
Only people invited will be able to find and join this room. You can change this at any time from room settings.
</p>
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
checked=""
class="_input_udcm8_24"
id="_r_86_"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="_r_86_"
>
Enable end-to-end encryption
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_88_"
>
You can't disable this later. Bridges & most bots won't work yet.
</span>
</div>
</div>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_udcm8_10"
>
<input
class="_input_udcm8_24"
id="_r_89_"
role="switch"
type="checkbox"
/>
<div
class="_ui_udcm8_34"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="_r_89_"
>
Encrypt state events
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_8b_"
>
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.
</span>
</div>
</div>
</form>
</div>
<div

View File

@ -72,6 +72,20 @@ describe("EncryptionEvent", () => {
);
});
it("should show the expected texts for experimental state event encryption", async () => {
client.enableEncryptedStateEvents = true;
event.event.content!["io.element.msc4362.encrypt_state_events"] = true;
renderEncryptionEvent(client, event);
await waitFor(() =>
checkTexts(
"Experimental state encryption 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.",
),
);
});
describe("with same previous algorithm", () => {
beforeEach(() => {
jest.spyOn(event, "getPrevContent").mockReturnValue({

View File

@ -18,6 +18,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { act } from "jest-matrix-react";
import {
stubClient,
@ -30,7 +31,11 @@ import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import WidgetStore from "../../src/stores/WidgetStore";
import WidgetUtils from "../../src/utils/WidgetUtils";
import { JitsiCall, ElementCall } from "../../src/models/Call";
import createRoom, { checkUserIsAllowedToChangeEncryption, canEncryptToAllUsers } from "../../src/createRoom";
import createRoom, {
checkUserIsAllowedToChangeEncryption,
canEncryptToAllUsers,
waitForRoomEncryption,
} from "../../src/createRoom";
import SettingsStore from "../../src/settings/SettingsStore";
import { ElementCallMemberEventType } from "../../src/call-types";
import DMRoomMap from "../../src/utils/DMRoomMap";
@ -58,6 +63,149 @@ describe("createRoom", () => {
});
});
it("creates a private room with encryption", async () => {
await createRoom(client, { createOpts: { preset: Preset.PrivateChat }, encryption: true });
expect(client.createRoom).toHaveBeenCalledWith({
preset: "private_chat",
visibility: "private",
initial_state: [
{ state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } },
{
state_key: "",
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
});
it("creates a private room with state event encryption", async () => {
// When we create a room with state encryption and details like name,
// topic, avatar
const oldCreateRoom = await createRoomWithStateEncryption(client, {
name: "Super-Secret Super-colliding Super Room",
topic: "super **Topic**",
avatar: "http://example.com/myavatar.png",
});
// Then it is created with the right m.room.encryption event
expect(oldCreateRoom).toHaveBeenCalledWith({
preset: "private_chat",
visibility: "private",
initial_state: [
{ state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } },
{
state_key: "",
type: "m.room.encryption",
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc4362.encrypt_state_events": true,
},
},
// Room name is NOT included, since it needs to be encrypted.
],
});
// And the room name, topic and avatar are set later
expect(client.setRoomName).toHaveBeenCalledWith("!1:example.org", "Super-Secret Super-colliding Super Room");
expect(client.setRoomTopic).toHaveBeenCalledWith(
"!1:example.org",
"super **Topic**",
"super <strong>Topic</strong>",
);
expect(client.sendStateEvent).toHaveBeenCalledWith(
"!1:example.org",
"m.room.avatar",
{ url: "http://example.com/myavatar.png" },
"",
);
});
it("creates a private room with state event encryption - file avatar", async () => {
// When we create a room with state encryption and a file avatar
client.uploadContent.mockResolvedValue({ content_uri: "mxc://foo.png" });
const oldCreateRoom = await createRoomWithStateEncryption(client, {
avatar: new File([], "myfile.png"),
});
// Then it is created with the right m.room.encryption event
expect(oldCreateRoom).toHaveBeenCalledWith({
preset: "private_chat",
visibility: "private",
initial_state: [
{ state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } },
{
state_key: "",
type: "m.room.encryption",
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc4362.encrypt_state_events": true,
},
},
// Room name is NOT included, since it needs to be encrypted.
],
});
// And the avatar is set later
expect(client.sendStateEvent).toHaveBeenCalledWith(
"!1:example.org",
"m.room.avatar",
{ url: "mxc://foo.png" },
"",
);
});
it("creates a private room with state event encryption - no details", async () => {
// When we create a room with state encryption and no further room
// details
const oldCreateRoom = await createRoomWithStateEncryption(client, {});
// Then it is created with the right m.room.encryption event
expect(oldCreateRoom).toHaveBeenCalledWith({
preset: "private_chat",
visibility: "private",
initial_state: [
{ state_key: "", type: "m.room.guest_access", content: { guest_access: "can_join" } },
{
state_key: "",
type: "m.room.encryption",
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc4362.encrypt_state_events": true,
},
},
// Room name is NOT included, since it needs to be encrypted.
],
});
// And the room name, topic and avatar were not set since we didn't
// supply them
expect(client.setRoomName).not.toHaveBeenCalled();
expect(client.setRoomTopic).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
it("cancels room creation if we time out before getting state events", async () => {
// We are not testing createRoom here, just waitForRoomEncryption, which is used
// inside. This allows us to pass in a shorter waitTime.
// Create a mock room that provides the needed methods
const { room_id: roomId } = await client.createRoom({});
const room = client.getRoom(roomId)!;
room.getLiveTimeline = jest
.fn()
.mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn() }) });
// Call waitForRoomEncryption with a small timeout ans expect an error
const error = new Error("Timed out while waiting for room to enable encryption");
await expect(waitForRoomEncryption(room, 1)).rejects.toThrow(error);
});
it("creates a private room in a space", async () => {
const roomId = await createRoom(client, { roomType: RoomType.Space });
const parentSpace = client.getRoom(roomId!)!;
@ -398,3 +546,42 @@ describe("checkUserIsAllowedToChangeEncryption()", () => {
);
});
});
interface RoomDetails {
name?: string;
topic?: string;
avatar?: string | File;
}
/**
* Call createRoom passing in stateEncryption: true, and set up the returned
* room to return true when hasEncryptionStateEvent is called, to avoid
* createRoom stalling forever waiting for an m.room.encryption event to arrive.
*/
async function createRoomWithStateEncryption(client: MatrixClient, roomDetails: RoomDetails) {
const oldCreateRoom = client.createRoom;
// @ts-ignore Replacing createRoom
client.createRoom = async (options) => {
const { room_id: roomId } = await oldCreateRoom(options);
const room = client.getRoom(roomId);
room!.hasEncryptionStateEvent = () => true;
return {
room_id: roomId,
};
};
// When we create a room asking for state encryption
await act(
async () =>
await createRoom(client, {
createOpts: {
preset: Preset.PrivateChat,
},
encryption: true,
stateEncryption: true,
...roomDetails,
}),
);
return oldCreateRoom;
}