mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-27 23:11:21 +00:00
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
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:
parent
6f0369e623
commit
ff3f069122
22
docs/labs.md
22
docs/labs.md
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user