mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-06 04:36:21 +02:00
Merge branch 'develop' of ssh://github.com/element-hq/element-web into t3chguy/pnpm
# Conflicts: # yarn.lock
This commit is contained in:
commit
6c5ec44dd9
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -3,3 +3,6 @@ contact_links:
|
||||
- name: Questions & support
|
||||
url: https://matrix.to/#/#element-web:matrix.org
|
||||
about: Please ask and answer questions here.
|
||||
- name: Bug report for the Element flatpak app
|
||||
url: https://github.com/flathub/im.riot.Riot/issues
|
||||
about: Please file bugs with the Flatpak application on the respective repository.
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@ -1,3 +1,36 @@
|
||||
Changes in [1.12.10](https://github.com/element-hq/element-web/releases/tag/v1.12.10) (2026-02-10)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Support additional\_creators in /upgraderoom (MSC4289) ([#31934](https://github.com/element-hq/element-web/pull/31934)). Contributed by @andybalaam.
|
||||
* Update room header icon for world\_readable rooms ([#31915](https://github.com/element-hq/element-web/pull/31915)). Contributed by @richvdh.
|
||||
* Show an icon in the room header for shared history ([#31879](https://github.com/element-hq/element-web/pull/31879)). Contributed by @richvdh.
|
||||
* Remove "history may be shared" banner. ([#31881](https://github.com/element-hq/element-web/pull/31881)). Contributed by @kaylendog.
|
||||
* Allow dismissing 'Key storage out of sync' temporarily ([#31455](https://github.com/element-hq/element-web/pull/31455)). Contributed by @andybalaam.
|
||||
* Add `resolutions` entry for `matrix-widget-api` to package.json ([#31851](https://github.com/element-hq/element-web/pull/31851)). Contributed by @toger5.
|
||||
* Improve visibility under contrast control mode ([#31847](https://github.com/element-hq/element-web/pull/31847)). Contributed by @t3chguy.
|
||||
* Unread Sorting - Add option for sorting in `OptionsMenuView` ([#31754](https://github.com/element-hq/element-web/pull/31754)). Contributed by @MidhunSureshR.
|
||||
* Unread sorting - Implement sorter and use it in the room list store ([#31723](https://github.com/element-hq/element-web/pull/31723)). Contributed by @MidhunSureshR.
|
||||
* Allow Element Call widgets to receive sticky events ([#31843](https://github.com/element-hq/element-web/pull/31843)). Contributed by @robintown.
|
||||
* Improve icon rendering accessibility ([#31791](https://github.com/element-hq/element-web/pull/31791)). Contributed by @t3chguy.
|
||||
* Add message preview toggle to room list header option ([#31821](https://github.com/element-hq/element-web/pull/31821)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Fix room list not being cleared ([#32438](https://github.com/element-hq/element-web/pull/32438)). Contributed by @RiotRobot.
|
||||
* Fix failure to update room info panel on joinrule change ([#31938](https://github.com/element-hq/element-web/pull/31938)). Contributed by @richvdh.
|
||||
* Throttle space notification state calculation ([#31922](https://github.com/element-hq/element-web/pull/31922)). Contributed by @dbkr.
|
||||
* Fix emoji verification responsive layout ([#31899](https://github.com/element-hq/element-web/pull/31899)). Contributed by @t3chguy.
|
||||
* Add patch for linkify to fix doctype handling ([#31900](https://github.com/element-hq/element-web/pull/31900)). Contributed by @dbkr.
|
||||
* Fix rooms with no messages appearing at the top of the room list ([#31798](https://github.com/element-hq/element-web/pull/31798)). Contributed by @MidhunSureshR.
|
||||
* Fix room list menu flashes when menu is closed ([#31868](https://github.com/element-hq/element-web/pull/31868)). Contributed by @florianduros.
|
||||
* Message preview toggle is inverted in room list header ([#31865](https://github.com/element-hq/element-web/pull/31865)). Contributed by @florianduros.
|
||||
* Fix duplicate toasts appearing for the same call if two events appear. ([#31693](https://github.com/element-hq/element-web/pull/31693)). Contributed by @Half-Shot.
|
||||
* Fix ability to send rageshake during session restore failure ([#31848](https://github.com/element-hq/element-web/pull/31848)). Contributed by @t3chguy.
|
||||
* Fix mis-alignment of `Threads` right panel title ([#31849](https://github.com/element-hq/element-web/pull/31849)). Contributed by @t3chguy.
|
||||
* Unset buttons does not include color inherit ([#31801](https://github.com/element-hq/element-web/pull/31801)). Contributed by @Philldomd.
|
||||
|
||||
|
||||
Changes in [1.12.9](https://github.com/element-hq/element-web/releases/tag/v1.12.9) (2026-01-27)
|
||||
================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
137
docs/MVVM.md
137
docs/MVVM.md
@ -27,66 +27,105 @@ This is anywhere your data or business logic comes from. If your view model is a
|
||||
1. Located in [`shared-components`](https://github.com/element-hq/element-web/tree/develop/packages/shared-components). Develop it in storybook!
|
||||
2. Views are simple react components (eg: `FooView`) with very little state and logic.
|
||||
3. Views must call `useViewModel` hook with the corresponding view model passed in as argument. This allows the view to re-render when something has changed in the view model. This entire mechanism is powered by [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore).
|
||||
4. Views should define the interface of the view model they expect:
|
||||
|
||||
```tsx
|
||||
// Snapshot is the data that your view-model provides which is rendered by the view.
|
||||
interface FooViewSnapshot {
|
||||
value: string;
|
||||
}
|
||||
|
||||
// To call function on the view model
|
||||
interface FooViewActions {
|
||||
doSomething: () => void;
|
||||
}
|
||||
|
||||
// ViewModel is an object (usually a class) that implements both the interfaces listed above.
|
||||
// https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts
|
||||
type FooViewModel = ViewModel<FooViewSnapshot> & FooViewActions;
|
||||
|
||||
interface FooViewProps {
|
||||
// Ideally the view only depends on the view model i.e you don't expect any other props here.
|
||||
vm: FooViewModel;
|
||||
}
|
||||
|
||||
function FooView({ vm }: FooViewProps) {
|
||||
const { value } = useViewModel(vm);
|
||||
return (
|
||||
<button type="button" onClick={() => vm.doSomething()}>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
4. Views should define the interface of the view model (see example below).
|
||||
5. Multiple views can share the same view model if necessary.
|
||||
6. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx)
|
||||
|
||||
**Example of view implementation**
|
||||
|
||||
```tsx
|
||||
// Snapshot is the data that your view-model provides which is rendered by the view.
|
||||
export interface FooViewSnapshot {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// To call function on the view model
|
||||
interface FooViewActions {
|
||||
setTitle: (title: string) => void;
|
||||
reloadDescription: () => void;
|
||||
}
|
||||
|
||||
// ViewModel is an object (usually a class) that implements both the interfaces listed above.
|
||||
// https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/ViewModel.ts
|
||||
export type FooViewModel = ViewModel<FooViewSnapshot> & FooViewActions;
|
||||
|
||||
interface FooViewProps {
|
||||
// Ideally the view only depends on the view model i.e you don't expect any other props here.
|
||||
vm: FooViewModel;
|
||||
}
|
||||
|
||||
export function FooView({ vm }: FooViewProps): JSX.Element {
|
||||
// useViewModel is a hook that subscribes to the view model and returns the snapshot. It also ensures that the component re-renders when the snapshot changes.
|
||||
const { title, description } = useViewModel(vm);
|
||||
return (
|
||||
<section>
|
||||
<h1>{title}</h1>
|
||||
{/* Bind setTitle action */}
|
||||
<button type="button" onClick={() => vm.setTitle("new title!")}>
|
||||
Set title
|
||||
</button>
|
||||
<p>{description}</p>
|
||||
{/* Bind reloadDescription action */}
|
||||
<button type="button" onClick={() => vm.reloadDescription()}>
|
||||
Reload description
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### View Model
|
||||
|
||||
1. A View model is a class extending [`BaseViewModel`](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/base/BaseViewModel.ts).
|
||||
2. Implements the interface defined in the view (e.g `FooViewModel` in the example above).
|
||||
3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` in the view model. This will trigger a re-render in the view.
|
||||
3. View models define a snapshot type that defines the data the view will consume. The snapshot is immutable and can only be changed by calling `this.snapshot.set(...)` or `this.snapshot.merge(...)` in the view model. This will trigger a re-render in the view.
|
||||
4. Call [`this.snapshot.merge(...)`](https://github.com/element-hq/element-web/blob/develop/packages/shared-components/src/viewmodel/Snapshot.ts#L32) to only update part of the snapshot.
|
||||
5. Avoid recomputing the entire snapshot when you only need to update a single field. For performance reasons, only recompute the fields that have actually changed. For example, if only `title` has changed, call `this.snapshot.merge({ title: newTitle })` rather than rebuilding the full snapshot object with all fields recomputed.
|
||||
6. View models can have props which are passed in the constructor. Props are usually used to pass in dependencies (eg: stores, sdk, etc) that the view model needs to do its work. They can also be used to pass in initial values for the snapshot.
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
propsValue: string;
|
||||
**Example of a view model implementation**
|
||||
|
||||
```ts
|
||||
import { type FooViewSnapshot, type FooViewModel as FooViewModelInterface } from "./FooView";
|
||||
|
||||
// Props are the arguments passed to the view model constructor. They are usually used to pass in dependencies (eg: stores, sdk, etc) that the view model needs to do its work. They can also be used to pass in initial values for the snapshot.
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an example view model that implements the FooViewModelInterface.
|
||||
* It extends the BaseViewModel class which provides common functionality for view models, such as managing subscriptions and snapshots.
|
||||
* The view model is responsible for managing the state of the view and providing actions that can be called from the view.
|
||||
* In this example, we have a title and description in the snapshot, and actions to set the title and reload the description.
|
||||
*/
|
||||
export class FooViewModel extends BaseViewModel<FooViewSnapshot, Props> implements FooViewModelInterface {
|
||||
public constructor(props: Props) {
|
||||
// Call super with initial snapshot
|
||||
super(props, { title: props.title, description: costlyDescriptionLoading() });
|
||||
}
|
||||
|
||||
class FooViewModel extends BaseViewModel<FooViewSnapshot, Props> implements FooViewModel {
|
||||
constructor(props: Props) {
|
||||
// Call super with initial snapshot
|
||||
super(props, { value: "initial" });
|
||||
}
|
||||
|
||||
public doSomething() {
|
||||
// Call this.snapshot.set to update the snapshot
|
||||
this.snapshot.set({ value: "changed" });
|
||||
}
|
||||
public setTitle(title: string): void {
|
||||
// We only update the title in the snapshot, description remains unchanged.
|
||||
// Calling `this.snapshot.merge` will trigger the view to re-render with the new snapshot value.
|
||||
// If we had called `this.snapshot.set`, we would have needed to provide the full snapshot value, including the description.
|
||||
this.snapshot.merge({ title });
|
||||
}
|
||||
```
|
||||
|
||||
4. A full example is available [here](https://github.com/element-hq/element-web/blob/develop/src/viewmodels/audio/AudioPlayerViewModel.ts)
|
||||
public reloadDescription(): void {
|
||||
// Simulate reloading the description by calling the costly function again and updating the snapshot.
|
||||
this.snapshot.merge({ description: costlyDescriptionLoading() });
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an example of how to access props in the view model. Props are passed in the constructor and can be accessed through `this.props`.
|
||||
*/
|
||||
public printProps(): void {
|
||||
// Access props through `this.props`
|
||||
console.log("Current props:", this.props);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `useViewModel` hook
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.12.9",
|
||||
"version": "1.12.10",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
|
||||
@ -30,6 +30,18 @@ module.exports = {
|
||||
"react/jsx-key": ["error"],
|
||||
"matrix-org/require-copyright-header": "error",
|
||||
"react-compiler/react-compiler": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "react",
|
||||
importNames: ["act"],
|
||||
message: "Please use @test-utils instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { act } from "react";
|
||||
import { render, screen } from "@test-utils";
|
||||
import React from "react";
|
||||
import { act, render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type JSHandle, type Page } from "@playwright/test";
|
||||
import { type ICreateRoomOpts, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type {
|
||||
CryptoEvent,
|
||||
EmojiMapping,
|
||||
@ -499,6 +499,51 @@ export const verify = async (app: ElementAppPage, bob: Bot) => {
|
||||
await roomInfo.getByRole("button", { name: "Got it" }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify two instances of the Element app by emoji.
|
||||
* @param aliceElementApp
|
||||
* @param bobElementApp
|
||||
*/
|
||||
export const verifyApp = async (
|
||||
aliceDisplayName: string,
|
||||
aliceElementApp: ElementAppPage,
|
||||
bobDisplayName: string,
|
||||
bobElementApp: ElementAppPage,
|
||||
) => {
|
||||
// Alice opens room info and starts verification.
|
||||
const aliceRoomInfo = aliceElementApp.page.locator(".mx_RightPanel");
|
||||
if (await aliceRoomInfo.isHidden()) {
|
||||
await aliceElementApp.toggleRoomInfoPanel();
|
||||
}
|
||||
|
||||
await aliceElementApp.page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
|
||||
await aliceRoomInfo.getByText("Bob").click();
|
||||
await aliceRoomInfo.getByRole("button", { name: "Verify" }).click();
|
||||
await aliceRoomInfo.getByRole("button", { name: "Start Verification" }).click();
|
||||
|
||||
// Navigate to the DM created by Alice and accept the invite
|
||||
const oldRoomId = await bobElementApp.getCurrentRoomIdFromUrl();
|
||||
await bobElementApp.viewRoomByName(aliceDisplayName);
|
||||
await bobElementApp.page.getByRole("button", { name: "Start chatting" }).click();
|
||||
|
||||
// Bob accepts the verification request.
|
||||
await bobElementApp.page.getByRole("button", { name: "Verify" }).click({ timeout: 5000 });
|
||||
|
||||
// This requires creating a DM, so can take a while. Give it a longer timeout.
|
||||
await aliceRoomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 });
|
||||
await bobElementApp.page.getByRole("button", { name: "Verify by emoji" }).click();
|
||||
|
||||
await aliceRoomInfo.getByRole("button", { name: "They match" }).click();
|
||||
await bobElementApp.page.getByRole("button", { name: "They match" }).click();
|
||||
|
||||
// Assert the verification was successful.
|
||||
await expect(aliceRoomInfo.getByText(`You've successfully verified ${bobDisplayName}!`)).toBeVisible();
|
||||
await expect(bobElementApp.page.getByText(`You've successfully verified ${aliceDisplayName}!`)).toBeVisible();
|
||||
|
||||
// Navigate Bob back to the old room.
|
||||
await bobElementApp.viewRoomById(oldRoomId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for a verifier to exist for a VerificationRequest
|
||||
*
|
||||
|
||||
@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createNewInstance } from "@element-hq/element-web-playwright-common";
|
||||
|
||||
import { test, expect } from "./index";
|
||||
import { ElementAppPage } from "../../../pages/ElementAppPage";
|
||||
import { createRoom, sendMessageInCurrentRoom, verifyApp } from "../../crypto/utils";
|
||||
|
||||
test.describe("Other people's devices section in Encryption tab", () => {
|
||||
test.use({
|
||||
displayName: "alice",
|
||||
});
|
||||
|
||||
test("unverified devices should be able to decrypt while global blacklist is not toggled", async ({
|
||||
page: alicePage,
|
||||
app: aliceElementApp,
|
||||
homeserver,
|
||||
browser,
|
||||
user: aliceCredentials,
|
||||
}, testInfo) => {
|
||||
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
// Create a second browser instance.
|
||||
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
|
||||
const bobPage = await createNewInstance(browser, bobCredentials, {});
|
||||
const bobElementApp = new ElementAppPage(bobPage);
|
||||
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice sends a message, which Bob should be able to decrypt
|
||||
await sendMessageInCurrentRoom(alicePage, "Decryptable");
|
||||
await expect(bobPage.getByText("Decryptable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unverified devices should not be able to decrypt while global blacklist is toggled", async ({
|
||||
page: alicePage,
|
||||
app: aliceElementApp,
|
||||
homeserver,
|
||||
browser,
|
||||
user: aliceCredentials,
|
||||
util,
|
||||
}, testInfo) => {
|
||||
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
// Enable blacklist toggle.
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const blacklistToggle = dialog.getByRole("switch", {
|
||||
name: "In encrypted rooms, only send messages to verified users",
|
||||
});
|
||||
await blacklistToggle.scrollIntoViewIfNeeded();
|
||||
await expect(blacklistToggle).toBeVisible();
|
||||
await blacklistToggle.click();
|
||||
await aliceElementApp.settings.closeDialog();
|
||||
|
||||
// Create a second browser instance.
|
||||
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
|
||||
const bobPage = await createNewInstance(browser, bobCredentials, {});
|
||||
const bobElementApp = new ElementAppPage(bobPage);
|
||||
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice sends a message, which Bob should not be able to decrypt
|
||||
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
|
||||
await expect(
|
||||
bobPage.getByText(
|
||||
"The sender has blocked you from receiving this message because your device is unverified",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("verified devices should be able to decrypt while global blacklist is toggled", async ({
|
||||
page: alicePage,
|
||||
app: aliceElementApp,
|
||||
homeserver,
|
||||
browser,
|
||||
user: aliceCredentials,
|
||||
util,
|
||||
}, testInfo) => {
|
||||
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
// Enable blacklist toggle.
|
||||
const dialog = await util.openEncryptionTab();
|
||||
const blacklistToggle = dialog.getByRole("switch", {
|
||||
name: "In encrypted rooms, only send messages to verified users",
|
||||
});
|
||||
await blacklistToggle.scrollIntoViewIfNeeded();
|
||||
await expect(blacklistToggle).toBeVisible();
|
||||
await blacklistToggle.click();
|
||||
await aliceElementApp.settings.closeDialog();
|
||||
|
||||
// Create a second browser instance.
|
||||
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
|
||||
const bobPage = await createNewInstance(browser, bobCredentials, {});
|
||||
const bobElementApp = new ElementAppPage(bobPage);
|
||||
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
|
||||
|
||||
// Create the room and invite bob
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite and dismisses the warnings.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable notifications
|
||||
await bobPage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
|
||||
await bobPage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
|
||||
|
||||
// Perform verification.
|
||||
await verifyApp("alice", aliceElementApp, "bob", bobElementApp);
|
||||
|
||||
// Alice sends a message, which Bob should be able to decrypt
|
||||
await sendMessageInCurrentRoom(alicePage, "Decryptable");
|
||||
await expect(bobPage.getByText("Decryptable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("setting per-room unverified blacklist toggle does not affect other rooms", async ({
|
||||
page: alicePage,
|
||||
app: aliceElementApp,
|
||||
homeserver,
|
||||
browser,
|
||||
user: aliceCredentials,
|
||||
}, testInfo) => {
|
||||
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
// Create a second browser instance.
|
||||
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
|
||||
const bobPage = await createNewInstance(browser, bobCredentials, {});
|
||||
const bobElementApp = new ElementAppPage(bobPage);
|
||||
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
|
||||
|
||||
// Alice creates the room and invite Bob.
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice configures her client to blacklist unverified users in this room.
|
||||
const dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy");
|
||||
await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click();
|
||||
await aliceElementApp.settings.closeDialog();
|
||||
|
||||
// Alice sends a message which Bob should not be able to decrypt.
|
||||
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
|
||||
await expect(
|
||||
bobPage.getByText(
|
||||
"The sender has blocked you from receiving this message because your device is unverified",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Alice dismisses key storage warnings, as they now hide the "New conversation" button.
|
||||
await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
|
||||
await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
|
||||
|
||||
// Alice creates a second room and invites Bob.
|
||||
await createRoom(alicePage, "TestRoom2", true);
|
||||
await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom2" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice sends a message in the new room, which Bob should be able to decrypt.
|
||||
await sendMessageInCurrentRoom(alicePage, "Decryptable");
|
||||
await expect(bobPage.getByText("Decryptable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("setting per-room unverified blacklist toggle overrides global toggle", async ({
|
||||
page: alicePage,
|
||||
app: aliceElementApp,
|
||||
homeserver,
|
||||
browser,
|
||||
user: aliceCredentials,
|
||||
util,
|
||||
}, testInfo) => {
|
||||
await aliceElementApp.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
// Enable blacklist toggle.
|
||||
let dialog = await util.openEncryptionTab();
|
||||
const blacklistToggle = dialog.getByRole("switch", {
|
||||
name: "In encrypted rooms, only send messages to verified users",
|
||||
});
|
||||
await blacklistToggle.scrollIntoViewIfNeeded();
|
||||
await expect(blacklistToggle).toBeVisible();
|
||||
await blacklistToggle.click();
|
||||
await aliceElementApp.settings.closeDialog();
|
||||
|
||||
// Create a second browser instance.
|
||||
const bobCredentials = await homeserver.registerUser(`user_${testInfo.testId}_bob`, "password", "bob");
|
||||
const bobPage = await createNewInstance(browser, bobCredentials, {});
|
||||
const bobElementApp = new ElementAppPage(bobPage);
|
||||
await bobElementApp.client.bootstrapCrossSigning(bobCredentials);
|
||||
|
||||
// Alice creates the room and invite Bob.
|
||||
await createRoom(alicePage, "TestRoom", true);
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice configures her client to allow sending to unverified users in this room.
|
||||
dialog = await aliceElementApp.settings.openRoomSettings("Security & Privacy");
|
||||
await dialog.getByRole("switch", { name: "Only send messages to verified users." }).click();
|
||||
await aliceElementApp.settings.closeDialog();
|
||||
|
||||
// Alice sends a message which Bob should be able to decrypt.
|
||||
await sendMessageInCurrentRoom(alicePage, "Decryptable");
|
||||
await expect(bobPage.getByText("Decryptable")).toBeVisible();
|
||||
|
||||
// Alice dismisses key storage warnings, as they now hide the "New conversation" button.
|
||||
await alicePage.getByRole("button", { name: "Dismiss" }).click(); // enable key storage
|
||||
await alicePage.getByRole("button", { name: "Yes, dismiss" }).click(); // enable key storage x2
|
||||
|
||||
// Alice creates a second room and invites Bob.
|
||||
await createRoom(alicePage, "TestRoom2", true);
|
||||
await aliceElementApp.toggleRoomInfoPanel(); // should not be necessary, called in body of below
|
||||
await aliceElementApp.inviteUserToCurrentRoom(bobCredentials.userId);
|
||||
|
||||
// Bob accepts the invite.
|
||||
await bobPage.getByRole("option", { name: "TestRoom2" }).click();
|
||||
await bobPage.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
// Alice sends a message in the new room, which Bob should not be able to decrypt.
|
||||
await sendMessageInCurrentRoom(alicePage, "Undecryptable");
|
||||
await expect(
|
||||
bobPage.getByText(
|
||||
"The sender has blocked you from receiving this message because your device is unverified",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -7,83 +7,25 @@ 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 MatrixEvent,
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
RoomStateEvent,
|
||||
type SyncState,
|
||||
ClientStoppedError,
|
||||
TypedEventEmitter,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { type MatrixClient, ClientStoppedError, TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
|
||||
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import {
|
||||
hideToast as hideSetupEncryptionToast,
|
||||
showToast as showSetupEncryptionToast,
|
||||
} from "./toasts/SetupEncryptionToast";
|
||||
import { isSecretStorageBeingAccessed } from "./SecurityManager";
|
||||
import { type ActionPayload } from "./dispatcher/payloads";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { recordClientInformation, removeClientInformation } from "./utils/device/clientInformation";
|
||||
import SettingsStore, { type CallbackFn } from "./settings/SettingsStore";
|
||||
import { asyncSomeParallel } from "./utils/arrays.ts";
|
||||
import DeviceListenerOtherDevices from "./device-listener/DeviceListenerOtherDevices.ts";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Unfortunately-named account data key used by Element X to indicate that the user
|
||||
* has chosen to disable server side key backups.
|
||||
*
|
||||
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
|
||||
*/
|
||||
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
||||
|
||||
/**
|
||||
* Account data key to indicate whether the user has chosen to enable or disable recovery.
|
||||
*/
|
||||
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
|
||||
import DeviceListenerCurrentDevice from "./device-listener/DeviceListenerCurrentDevice.ts";
|
||||
import type DeviceState from "./device-listener/DeviceState.ts";
|
||||
|
||||
const logger = baseLogger.getChild("DeviceListener:");
|
||||
|
||||
/**
|
||||
* The state of the device and the user's account.
|
||||
*/
|
||||
export type DeviceState =
|
||||
/**
|
||||
* The device is in a good state.
|
||||
*/
|
||||
| "ok"
|
||||
/**
|
||||
* The user needs to set up recovery.
|
||||
*/
|
||||
| "set_up_recovery"
|
||||
/**
|
||||
* The device is not verified.
|
||||
*/
|
||||
| "verify_this_session"
|
||||
/**
|
||||
* Key storage is out of sync (keys are missing locally, from recovery, or both).
|
||||
*/
|
||||
| "key_storage_out_of_sync"
|
||||
/**
|
||||
* Key storage is not enabled, and has not been marked as purposely disabled.
|
||||
*/
|
||||
| "turn_on_key_storage"
|
||||
/**
|
||||
* The user's identity needs resetting, due to missing keys.
|
||||
*/
|
||||
| "identity_needs_reset";
|
||||
|
||||
/**
|
||||
* The events emitted by {@link DeviceListener}
|
||||
*/
|
||||
@ -98,21 +40,22 @@ type EventHandlerMap = {
|
||||
export default class DeviceListener extends TypedEventEmitter<DeviceListenerEvents, EventHandlerMap> {
|
||||
private dispatcherRef?: string;
|
||||
|
||||
/** All the information about whether other devices are verified. */
|
||||
/**
|
||||
* All the information about whether other devices are verified. Only set
|
||||
* if `running` is true, otherwise undefined.
|
||||
*/
|
||||
public otherDevices?: DeviceListenerOtherDevices;
|
||||
|
||||
// has the user dismissed any of the various nag toasts to setup encryption on this device?
|
||||
private dismissedThisDeviceToast = false;
|
||||
/** Cache of the info about the current key backup on the server. */
|
||||
private keyBackupInfo: KeyBackupInfo | null = null;
|
||||
/** When `keyBackupInfo` was last updated */
|
||||
private keyBackupFetchedAt: number | null = null;
|
||||
/** All the information about whether this device's encrypytion is OK. Only
|
||||
* set if `running` is true, otherwise undefined.
|
||||
*/
|
||||
public currentDevice?: DeviceListenerCurrentDevice;
|
||||
|
||||
private running = false;
|
||||
// The client with which the instance is running. Only set if `running` is true, otherwise undefined.
|
||||
private client?: MatrixClient;
|
||||
private shouldRecordClientInformation = false;
|
||||
private deviceClientInformationSettingWatcherRef: string | undefined;
|
||||
private deviceState: DeviceState = "ok";
|
||||
|
||||
// Remember the current analytics state to avoid sending the same event multiple times.
|
||||
private analyticsVerificationState?: string;
|
||||
@ -127,15 +70,9 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
this.running = true;
|
||||
|
||||
this.otherDevices = new DeviceListenerOtherDevices(this, matrixClient);
|
||||
this.currentDevice = new DeviceListenerCurrentDevice(this, matrixClient, logger);
|
||||
|
||||
this.client = matrixClient;
|
||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
this.client.on(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
|
||||
this.shouldRecordClientInformation = SettingsStore.getValue("deviceClientInformationOptIn");
|
||||
// only configurable in config, so we don't need to watch the value
|
||||
@ -151,22 +88,14 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
|
||||
public stop(): void {
|
||||
this.running = false;
|
||||
if (this.client) {
|
||||
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSingingKeysChanged);
|
||||
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.removeListener(ClientEvent.Sync, this.onSync);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
|
||||
|
||||
dis.unregister(this.dispatcherRef);
|
||||
|
||||
this.dispatcherRef = undefined;
|
||||
this.otherDevices?.stop();
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.currentDevice?.stop();
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
@ -201,22 +130,21 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
}
|
||||
|
||||
public dismissEncryptionSetup(): void {
|
||||
this.dismissedThisDeviceToast = true;
|
||||
this.recheck();
|
||||
this.currentDevice?.dismissEncryptionSetup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data "m.org.matrix.custom.backup_disabled" to { "disabled": true }.
|
||||
*/
|
||||
public async recordKeyBackupDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
await this.currentDevice?.recordKeyBackupDisabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data to indicate that recovery is disabled
|
||||
*/
|
||||
public async recordRecoveryDisabled(): Promise<void> {
|
||||
await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
|
||||
await this.currentDevice?.recordRecoveryDisabled();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -265,10 +193,11 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
*/
|
||||
public async keyStorageOutOfSyncNeedsBackupReset(forgotRecovery: boolean): Promise<boolean> {
|
||||
const crypto = this.client?.getCrypto();
|
||||
if (!crypto) {
|
||||
const thisDevice = this.currentDevice;
|
||||
if (!(crypto && thisDevice)) {
|
||||
return false;
|
||||
}
|
||||
const shouldHaveBackup = !(await this.recheckBackupDisabled(this.client!));
|
||||
const shouldHaveBackup = !(await thisDevice.recheckBackupDisabled());
|
||||
const backupKeyCached = (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||
const backupKeyStored = await this.client!.isKeyBackupKeyStored();
|
||||
|
||||
@ -279,101 +208,12 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
}
|
||||
}
|
||||
|
||||
private onUserTrustStatusChanged = (userId: string): void => {
|
||||
if (!this.client) return;
|
||||
if (userId !== this.client.getUserId()) return;
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onKeyBackupStatusChanged = (): void => {
|
||||
logger.info("Backup status changed");
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onCrossSingingKeysChanged = (): void => {
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onAccountData = (ev: MatrixEvent): void => {
|
||||
// User may have:
|
||||
// * migrated SSSS to symmetric
|
||||
// * uploaded keys to secret storage
|
||||
// * completed secret storage creation
|
||||
// * disabled key backup
|
||||
// which result in account data changes affecting checks below.
|
||||
if (
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
|
||||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
private onSync = (state: SyncState, prevState: SyncState | null): void => {
|
||||
if (state === "PREPARED" && prevState === null) {
|
||||
this.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() !== EventType.RoomEncryption) return;
|
||||
|
||||
// If a room changes to encrypted, re-check as it may be our first
|
||||
// encrypted room. This also catches encrypted room creation as well.
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
private onAction = ({ action }: ActionPayload): void => {
|
||||
if (action !== Action.OnLoggedIn) return;
|
||||
this.recheck();
|
||||
this.updateClientInformation();
|
||||
};
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
// Receiving a 4S secret can mean we are in sync where we were not before.
|
||||
if (event.getType() === EventType.SecretSend) this.recheck();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the key backup information from the server.
|
||||
*
|
||||
* The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls.
|
||||
*
|
||||
* @returns The key backup info from the server, or `null` if there is no key backup.
|
||||
*/
|
||||
private async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||
if (!this.client) return null;
|
||||
const now = new Date().getTime();
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) return null;
|
||||
|
||||
if (
|
||||
!this.keyBackupInfo ||
|
||||
!this.keyBackupFetchedAt ||
|
||||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
|
||||
) {
|
||||
this.keyBackupInfo = await crypto.getKeyBackupInfo();
|
||||
this.keyBackupFetchedAt = now;
|
||||
}
|
||||
return this.keyBackupInfo;
|
||||
}
|
||||
|
||||
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
|
||||
// If we're in the middle of a secret storage operation, we're likely
|
||||
// modifying the state involved here, so don't add new toasts to setup.
|
||||
if (isSecretStorageBeingAccessed()) return false;
|
||||
// Show setup toasts once the user is in at least one encrypted room.
|
||||
const cli = this.client;
|
||||
const cryptoApi = cli?.getCrypto();
|
||||
if (!cli || !cryptoApi) return false;
|
||||
|
||||
return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId));
|
||||
}
|
||||
|
||||
public recheck(): void {
|
||||
this.doRecheck().catch((e) => {
|
||||
if (e instanceof ClientStoppedError) {
|
||||
@ -405,127 +245,10 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
return;
|
||||
}
|
||||
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const allCrossSigningSecretsCached =
|
||||
crossSigningStatus.privateKeysCachedLocally.masterKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
|
||||
const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
|
||||
|
||||
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
|
||||
|
||||
const isCurrentDeviceTrusted = Boolean(
|
||||
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
|
||||
);
|
||||
|
||||
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
|
||||
const backupDisabled = await this.recheckBackupDisabled(cli);
|
||||
|
||||
// We warn if key backup upload is turned off and we have not explicitly
|
||||
// said we are OK with that.
|
||||
const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
// We warn if key backup is set up, but we don't have the decryption
|
||||
// key, so can't fetch keys from backup.
|
||||
const keyBackupDownloadIsOk =
|
||||
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||
|
||||
const allSystemsReady =
|
||||
isCurrentDeviceTrusted &&
|
||||
allCrossSigningSecretsCached &&
|
||||
keyBackupUploadIsOk &&
|
||||
recoveryIsOk &&
|
||||
keyBackupDownloadIsOk;
|
||||
await this.currentDevice?.recheck(logSpan);
|
||||
await this.otherDevices?.recheck(logSpan);
|
||||
|
||||
await this.reportCryptoSessionStateToAnalytics(cli);
|
||||
|
||||
if (allSystemsReady) {
|
||||
logSpan.info("No toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
} else {
|
||||
// make sure our keys are finished downloading
|
||||
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
|
||||
|
||||
if (!isCurrentDeviceTrusted) {
|
||||
// the current device is not trusted: prompt the user to verify
|
||||
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
|
||||
await this.setDeviceState("verify_this_session", logSpan);
|
||||
} else if (!allCrossSigningSecretsCached) {
|
||||
// cross signing ready & device trusted, but we are missing secrets from our local cache.
|
||||
// prompt the user to enter their recovery key.
|
||||
logSpan.info(
|
||||
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
|
||||
crossSigningStatus.privateKeysCachedLocally,
|
||||
crossSigningStatus.privateKeysInSecretStorage,
|
||||
);
|
||||
await this.setDeviceState(
|
||||
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
|
||||
logSpan,
|
||||
);
|
||||
} else if (!keyBackupUploadIsOk) {
|
||||
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
|
||||
await this.setDeviceState("turn_on_key_storage", logSpan);
|
||||
} else if (secretStorageStatus.defaultKeyId === null) {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (recoveryDisabled) {
|
||||
logSpan.info("Recovery disabled: no toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
} else if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
|
||||
await this.setDeviceState("set_up_recovery", logSpan);
|
||||
} else {
|
||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
}
|
||||
} else {
|
||||
// If we get here, then we are verified, have key backup, and
|
||||
// 4S, but allSystemsReady is false, which means that either
|
||||
// secretStorageStatus.ready is false (which means that 4S
|
||||
// doesn't have all the secrets), or we don't have the backup
|
||||
// key cached locally. If any of the cross-signing keys are
|
||||
// missing locally, that is handled by the
|
||||
// `!allCrossSigningSecretsCached` branch above.
|
||||
logSpan.warn("4S is missing secrets or backup key not cached", {
|
||||
crossSigningReady,
|
||||
secretStorageStatus,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
keyBackupDownloadIsOk,
|
||||
});
|
||||
await this.setDeviceState("key_storage_out_of_sync", logSpan);
|
||||
}
|
||||
}
|
||||
|
||||
await this.otherDevices?.recheck(logSpan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account data for `backup_disabled`. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckBackupDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const backupDisabled = await cli.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has disabled recovery. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
|
||||
// Recovery is disabled only if the `enabled` flag is set to `false`.
|
||||
// If it is missing, or set to any other value, we consider it as
|
||||
// not-disabled, and will prompt the user to create recovery (if
|
||||
// missing).
|
||||
return recoveryStatus?.enabled === false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -534,23 +257,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
* self-verified device that is using key backup and recovery.
|
||||
*/
|
||||
public getDeviceState(): DeviceState {
|
||||
return this.deviceState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of the device, and perform any actions necessary in
|
||||
* response to the state changing.
|
||||
*/
|
||||
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
|
||||
this.deviceState = newState;
|
||||
this.emit(DeviceListenerEvents.DeviceState, newState);
|
||||
if (newState === "ok" || this.dismissedThisDeviceToast) {
|
||||
hideSetupEncryptionToast();
|
||||
} else if (await this.shouldShowSetupEncryptionToast()) {
|
||||
showSetupEncryptionToast(newState);
|
||||
} else {
|
||||
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
|
||||
}
|
||||
return this.currentDevice?.getDeviceState() ?? "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
@ -564,7 +271,7 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
||||
const secretStorageReady = secretStorageStatus.ready;
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const backupInfo = await this.getKeyBackupInfo();
|
||||
const backupInfo = await this.currentDevice?.getKeyBackupInfo();
|
||||
const is4SEnabled = secretStorageStatus.defaultKeyId != null;
|
||||
const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!);
|
||||
|
||||
@ -618,39 +325,6 @@ export default class DeviceListener extends TypedEventEmitter<DeviceListenerEven
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Is key backup enabled? Use a cached answer if we have one.
|
||||
*/
|
||||
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
|
||||
if (!this.client) {
|
||||
// To preserve existing behaviour, if there is no client, we
|
||||
// pretend key backup upload is on.
|
||||
//
|
||||
// Someone looking to improve this code could try throwing an error
|
||||
// here since we don't expect client to be undefined.
|
||||
return true;
|
||||
}
|
||||
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
// If there is no crypto, there is no key backup
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we've already cached the answer, return it.
|
||||
if (this.cachedKeyBackupUploadActive !== undefined) {
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
}
|
||||
|
||||
// Fetch the answer and cache it
|
||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
|
||||
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
};
|
||||
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
|
||||
|
||||
private onRecordClientInformationSettingChange: CallbackFn = (
|
||||
_originalSettingName,
|
||||
_roomId,
|
||||
|
||||
@ -1793,8 +1793,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
const crypto = cli.getCrypto();
|
||||
if (crypto) {
|
||||
const blacklistEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "blacklistUnverifiedDevices");
|
||||
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
|
||||
crypto.globalBlacklistUnverifiedDevices = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE,
|
||||
"blacklistUnverifiedDevices",
|
||||
);
|
||||
SettingsStore.watchSetting(
|
||||
"blacklistUnverifiedDevices",
|
||||
null,
|
||||
(_settingName, _roomId, atLevel, blacklistEnabled: boolean) => {
|
||||
if (atLevel != SettingLevel.DEVICE) {
|
||||
return;
|
||||
}
|
||||
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Cannot be done in OnLoggedIn as at that point the AccountSettingsHandler doesn't yet have a client
|
||||
|
||||
@ -10,9 +10,10 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
import DeviceListener from "../../../../DeviceListener";
|
||||
import { useEventEmitterAsyncState } from "../../../../hooks/useEventEmitter";
|
||||
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice";
|
||||
|
||||
interface KeyStoragePanelState {
|
||||
/**
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type MouseEventHandler, useState } from "react";
|
||||
import React, { type JSX, type MouseEventHandler, useCallback, useState } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from "@vector-im/compound-web";
|
||||
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { EncryptionCard } from "./EncryptionCard";
|
||||
@ -30,8 +31,9 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
|
||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
|
||||
import { EncryptionCardButtons } from "./EncryptionCardButtons";
|
||||
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
|
||||
import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
|
||||
import DeviceListener from "../../../../DeviceListener";
|
||||
import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup";
|
||||
import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../device-listener/DeviceListenerCurrentDevice.ts";
|
||||
|
||||
/**
|
||||
* The possible states of the component.
|
||||
@ -78,6 +80,11 @@ export function ChangeRecoveryKey({
|
||||
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
|
||||
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");
|
||||
|
||||
const onCancelClickWrapper = useCallback(() => {
|
||||
logger.debug("ChangeRecoveryKey: user cancelled");
|
||||
onCancelClick();
|
||||
}, [onCancelClick]);
|
||||
|
||||
// We create a new recovery key, the recovery key will be displayed to the user
|
||||
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
|
||||
// Waiting for the recovery key to be generated
|
||||
@ -90,7 +97,7 @@ export function ChangeRecoveryKey({
|
||||
content = (
|
||||
<InformationPanel
|
||||
onContinueClick={() => setState("save_key_setup_flow")}
|
||||
onCancelClick={onCancelClick}
|
||||
onCancelClick={onCancelClickWrapper}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@ -108,7 +115,7 @@ export function ChangeRecoveryKey({
|
||||
: "confirm_key_setup_flow",
|
||||
)
|
||||
}
|
||||
onCancelClick={onCancelClick}
|
||||
onCancelClick={onCancelClickWrapper}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@ -119,7 +126,7 @@ export function ChangeRecoveryKey({
|
||||
<KeyForm
|
||||
// encodedPrivateKey is always defined, the optional typing is incorrect
|
||||
recoveryKey={recoveryKey.encodedPrivateKey!}
|
||||
onCancelClick={onCancelClick}
|
||||
onCancelClick={onCancelClickWrapper}
|
||||
onSubmit={async () => {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) return onFinish();
|
||||
@ -132,6 +139,9 @@ export function ChangeRecoveryKey({
|
||||
// keyStorageOutOfSyncNeedsBackupReset won't be able to check
|
||||
// the backup state.
|
||||
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true);
|
||||
logger.debug(
|
||||
`ChangeRecoveryKey: user confirmed recovery key; now doing change. needsBackupReset: ${needsBackupReset}`,
|
||||
);
|
||||
await deviceListener.whilePaused(async () => {
|
||||
// We need to enable the cache to avoid to prompt the user to enter the new key
|
||||
// when we will try to access the secret storage during the bootstrap
|
||||
@ -177,9 +187,9 @@ export function ChangeRecoveryKey({
|
||||
<>
|
||||
<Breadcrumb
|
||||
backLabel={_t("action|back")}
|
||||
onBackClick={onCancelClick}
|
||||
onBackClick={onCancelClickWrapper}
|
||||
pages={pages}
|
||||
onPageClick={onCancelClick}
|
||||
onPageClick={onCancelClickWrapper}
|
||||
/>
|
||||
<EncryptionCard
|
||||
Icon={KeyIcon}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import React, { type JSX } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
@ -78,6 +79,9 @@ export function RecoveryPanelOutOfSync({
|
||||
// the backup state.
|
||||
const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false);
|
||||
|
||||
logger.debug(
|
||||
`RecoveryPanelOutOfSync: user clicked 'Enter recovery key'. needsBackupReset: ${needsBackupReset}`,
|
||||
);
|
||||
try {
|
||||
// pause the device listener because we could be making lots
|
||||
// of changes, and don't want toasts to pop up and disappear
|
||||
|
||||
@ -24,8 +24,9 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
|
||||
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
|
||||
import { KeyStoragePanel } from "../../encryption/KeyStoragePanel";
|
||||
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
|
||||
import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener";
|
||||
import DeviceListener, { DeviceListenerEvents } from "../../../../../DeviceListener";
|
||||
import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
|
||||
import type DeviceState from "../../../../../device-listener/DeviceState";
|
||||
|
||||
/**
|
||||
* The state in the encryption settings tab.
|
||||
|
||||
420
src/device-listener/DeviceListenerCurrentDevice.ts
Normal file
420
src/device-listener/DeviceListenerCurrentDevice.ts
Normal file
@ -0,0 +1,420 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { type LogSpan, type BaseLogger, type Logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
type MatrixEvent,
|
||||
type MatrixClient,
|
||||
EventType,
|
||||
type SyncState,
|
||||
RoomStateEvent,
|
||||
ClientEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type DeviceListener from "../DeviceListener";
|
||||
import type DeviceState from "./DeviceState";
|
||||
import { DeviceListenerEvents } from "../DeviceListener";
|
||||
import {
|
||||
hideToast as hideSetupEncryptionToast,
|
||||
showToast as showSetupEncryptionToast,
|
||||
} from "../toasts/SetupEncryptionToast";
|
||||
import { isSecretStorageBeingAccessed } from "../SecurityManager";
|
||||
import { asyncSomeParallel } from "../utils/arrays";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Unfortunately-named account data key used by Element X to indicate that the user
|
||||
* has chosen to disable server side key backups.
|
||||
*
|
||||
* We need to set and honour this to prevent Element X from automatically turning key backup back on.
|
||||
*/
|
||||
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
|
||||
|
||||
/**
|
||||
* Account data key to indicate whether the user has chosen to enable or disable recovery.
|
||||
*/
|
||||
export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
|
||||
|
||||
/**
|
||||
* Handles all of DeviceListener's work that relates to the current device.
|
||||
*/
|
||||
export default class DeviceListenerCurrentDevice {
|
||||
/**
|
||||
* The DeviceListener launching this instance.
|
||||
*/
|
||||
private deviceListener: DeviceListener;
|
||||
|
||||
/**
|
||||
* The Matrix client in use by the current user.
|
||||
*/
|
||||
private client: MatrixClient;
|
||||
|
||||
/**
|
||||
* A Logger we use to write our debug information.
|
||||
*/
|
||||
private logger: Logger;
|
||||
|
||||
/**
|
||||
* Has the user dismissed any of the various nag toasts to setup encryption
|
||||
* on this device?
|
||||
*/
|
||||
private dismissedThisDeviceToast = false;
|
||||
|
||||
/**
|
||||
* Cache of the info about the current key backup on the server.
|
||||
*/
|
||||
private keyBackupInfo: KeyBackupInfo | null = null;
|
||||
|
||||
/**
|
||||
* When `keyBackupInfo` was last updated (in ms since the epoch).
|
||||
*/
|
||||
private keyBackupFetchedAt: number | null = null;
|
||||
|
||||
/**
|
||||
* What is the current state of the device: is its crypto OK?
|
||||
*/
|
||||
private deviceState: DeviceState = "ok";
|
||||
|
||||
/**
|
||||
* Was key backup upload active last time we checked?
|
||||
*/
|
||||
private cachedKeyBackupUploadActive: boolean | undefined = undefined;
|
||||
|
||||
public constructor(deviceListener: DeviceListener, client: MatrixClient, logger: Logger) {
|
||||
this.deviceListener = deviceListener;
|
||||
this.client = client;
|
||||
this.logger = logger;
|
||||
|
||||
this.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged);
|
||||
this.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
this.client.on(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for events and clear the stored information.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.dismissedThisDeviceToast = false;
|
||||
this.keyBackupInfo = null;
|
||||
this.keyBackupFetchedAt = null;
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
|
||||
this.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
|
||||
this.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged);
|
||||
this.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChanged);
|
||||
this.client.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
this.client.removeListener(ClientEvent.Sync, this.onSync);
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user dismissed the Key Storage out of Sync toast, so we won't nag
|
||||
* them again until they refresh or restart the app.
|
||||
*/
|
||||
public dismissEncryptionSetup(): void {
|
||||
this.dismissedThisDeviceToast = true;
|
||||
this.deviceListener.recheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data "m.org.matrix.custom.backup_disabled" to `{ "disabled": true }`.
|
||||
*/
|
||||
public async recordKeyBackupDisabled(): Promise<void> {
|
||||
await this.client.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account data to indicate that recovery is disabled
|
||||
*/
|
||||
public async recordRecoveryDisabled(): Promise<void> {
|
||||
await this.client.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a toast if our crypto is in an unexpected state, or if we want to
|
||||
* nag the user about setting up more stuff.
|
||||
*/
|
||||
public async recheck(logSpan: LogSpan): Promise<void> {
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
return;
|
||||
}
|
||||
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
const secretStorageStatus = await crypto.getSecretStorageStatus();
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const allCrossSigningSecretsCached =
|
||||
crossSigningStatus.privateKeysCachedLocally.masterKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.selfSigningKey &&
|
||||
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
|
||||
const recoveryDisabled = await this.recheckRecoveryDisabled(this.client);
|
||||
|
||||
const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled;
|
||||
|
||||
const isCurrentDeviceTrusted = Boolean(
|
||||
(await crypto.getDeviceVerificationStatus(this.client.getSafeUserId(), this.client.deviceId!))
|
||||
?.crossSigningVerified,
|
||||
);
|
||||
|
||||
const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
|
||||
const backupDisabled = await this.recheckBackupDisabled();
|
||||
|
||||
// We warn if key backup upload is turned off and we have not explicitly
|
||||
// said we are OK with that.
|
||||
const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled;
|
||||
|
||||
// We warn if key backup is set up, but we don't have the decryption
|
||||
// key, so can't fetch keys from backup.
|
||||
const keyBackupDownloadIsOk =
|
||||
!keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null;
|
||||
|
||||
const allSystemsReady =
|
||||
isCurrentDeviceTrusted &&
|
||||
allCrossSigningSecretsCached &&
|
||||
keyBackupUploadIsOk &&
|
||||
recoveryIsOk &&
|
||||
keyBackupDownloadIsOk;
|
||||
|
||||
if (allSystemsReady) {
|
||||
logSpan.info("No toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
} else {
|
||||
// make sure our keys are finished downloading
|
||||
await crypto.getUserDeviceInfo([this.client.getSafeUserId()]);
|
||||
|
||||
if (!isCurrentDeviceTrusted) {
|
||||
// the current device is not trusted: prompt the user to verify
|
||||
logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION");
|
||||
await this.setDeviceState("verify_this_session", logSpan);
|
||||
} else if (!allCrossSigningSecretsCached) {
|
||||
// cross signing ready & device trusted, but we are missing secrets from our local cache.
|
||||
// prompt the user to enter their recovery key.
|
||||
logSpan.info(
|
||||
"Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC",
|
||||
crossSigningStatus.privateKeysCachedLocally,
|
||||
crossSigningStatus.privateKeysInSecretStorage,
|
||||
);
|
||||
await this.setDeviceState(
|
||||
crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset",
|
||||
logSpan,
|
||||
);
|
||||
} else if (!keyBackupUploadIsOk) {
|
||||
logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE");
|
||||
await this.setDeviceState("turn_on_key_storage", logSpan);
|
||||
} else if (secretStorageStatus.defaultKeyId === null) {
|
||||
// The user just hasn't set up 4S yet: if they have key
|
||||
// backup, prompt them to turn on recovery too. (If not, they
|
||||
// have explicitly opted out, so don't hassle them.)
|
||||
if (recoveryDisabled) {
|
||||
logSpan.info("Recovery disabled: no toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
} else if (keyBackupUploadActive) {
|
||||
logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY");
|
||||
await this.setDeviceState("set_up_recovery", logSpan);
|
||||
} else {
|
||||
logSpan.info("No default 4S key but backup disabled: no toast needed");
|
||||
await this.setDeviceState("ok", logSpan);
|
||||
}
|
||||
} else {
|
||||
// If we get here, then we are verified, have key backup, and
|
||||
// 4S, but allSystemsReady is false, which means that either
|
||||
// secretStorageStatus.ready is false (which means that 4S
|
||||
// doesn't have all the secrets), or we don't have the backup
|
||||
// key cached locally. If any of the cross-signing keys are
|
||||
// missing locally, that is handled by the
|
||||
// `!allCrossSigningSecretsCached` branch above.
|
||||
logSpan.warn("4S is missing secrets or backup key not cached", {
|
||||
crossSigningReady,
|
||||
secretStorageStatus,
|
||||
allCrossSigningSecretsCached,
|
||||
isCurrentDeviceTrusted,
|
||||
keyBackupDownloadIsOk,
|
||||
});
|
||||
await this.setDeviceState("key_storage_out_of_sync", logSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of the device and the user's account. The device/account
|
||||
* state indicates what action the user must take in order to get a
|
||||
* self-verified device that is using key backup and recovery.
|
||||
*/
|
||||
public getDeviceState(): DeviceState {
|
||||
return this.deviceState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of the device, and perform any actions necessary in
|
||||
* response to the state changing.
|
||||
*/
|
||||
private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise<void> {
|
||||
this.deviceState = newState;
|
||||
|
||||
this.deviceListener.emit(DeviceListenerEvents.DeviceState, newState);
|
||||
|
||||
if (newState === "ok" || this.dismissedThisDeviceToast) {
|
||||
hideSetupEncryptionToast();
|
||||
} else if (await this.shouldShowSetupEncryptionToast()) {
|
||||
showSetupEncryptionToast(newState);
|
||||
} else {
|
||||
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account data for `backup_disabled`. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
public async recheckBackupDisabled(): Promise<boolean> {
|
||||
const backupDisabled = await this.client.getAccountDataFromServer(BACKUP_DISABLED_ACCOUNT_DATA_KEY);
|
||||
return !!backupDisabled?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has disabled recovery. If this is the first time,
|
||||
* fetch it from the server (in case the initial sync has not finished).
|
||||
* Otherwise, fetch it from the store as normal.
|
||||
*/
|
||||
private async recheckRecoveryDisabled(cli: MatrixClient): Promise<boolean> {
|
||||
const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
|
||||
// Recovery is disabled only if the `enabled` flag is set to `false`.
|
||||
// If it is missing, or set to any other value, we consider it as
|
||||
// not-disabled, and will prompt the user to create recovery (if
|
||||
// missing).
|
||||
return recoveryStatus?.enabled === false;
|
||||
}
|
||||
|
||||
private onUserTrustStatusChanged = (userId: string): void => {
|
||||
if (userId !== this.client.getUserId()) return;
|
||||
this.deviceListener.recheck();
|
||||
};
|
||||
|
||||
private onKeyBackupStatusChanged = (): void => {
|
||||
this.logger.info("Backup status changed");
|
||||
this.cachedKeyBackupUploadActive = undefined;
|
||||
this.deviceListener.recheck();
|
||||
};
|
||||
|
||||
private onCrossSigningKeysChanged = (): void => {
|
||||
this.deviceListener.recheck();
|
||||
};
|
||||
|
||||
private onAccountData = (ev: MatrixEvent): void => {
|
||||
// User may have:
|
||||
// * migrated SSSS to symmetric
|
||||
// * uploaded keys to secret storage
|
||||
// * completed secret storage creation
|
||||
// * disabled key backup
|
||||
// which result in account data changes affecting checks below.
|
||||
if (
|
||||
ev.getType().startsWith("m.secret_storage.") ||
|
||||
ev.getType().startsWith("m.cross_signing.") ||
|
||||
ev.getType() === "m.megolm_backup.v1" ||
|
||||
ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
|
||||
ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
|
||||
) {
|
||||
this.deviceListener.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
private onSync = (state: SyncState, prevState: SyncState | null): void => {
|
||||
if (state === "PREPARED" && prevState === null) {
|
||||
this.deviceListener.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getType() !== EventType.RoomEncryption) return;
|
||||
|
||||
// If a room changes to encrypted, re-check as it may be our first
|
||||
// encrypted room. This also catches encrypted room creation as well.
|
||||
this.deviceListener.recheck();
|
||||
};
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
// Receiving a 4S secret can mean we are in sync where we were not before.
|
||||
if (event.getType() === EventType.SecretSend) {
|
||||
this.deviceListener.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the key backup information from the server.
|
||||
*
|
||||
* The result is cached for `KEY_BACKUP_POLL_INTERVAL` ms to avoid repeated API calls.
|
||||
*
|
||||
* @returns The key backup info from the server, or `null` if there is no key backup.
|
||||
*/
|
||||
public async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||
const now = new Date().getTime();
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) return null;
|
||||
|
||||
if (
|
||||
!this.keyBackupInfo ||
|
||||
!this.keyBackupFetchedAt ||
|
||||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
|
||||
) {
|
||||
this.keyBackupInfo = await crypto.getKeyBackupInfo();
|
||||
this.keyBackupFetchedAt = now;
|
||||
}
|
||||
|
||||
return this.keyBackupInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the user in at least one encrypted room?
|
||||
*/
|
||||
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
|
||||
// If we're in the middle of a secret storage operation, we're likely
|
||||
// modifying the state involved here, so don't add new toasts to setup.
|
||||
if (isSecretStorageBeingAccessed()) return false;
|
||||
|
||||
// Show setup toasts once the user is in at least one encrypted room.
|
||||
const cryptoApi = this.client.getCrypto();
|
||||
if (!cryptoApi) return false;
|
||||
|
||||
return await asyncSomeParallel(this.client.getRooms(), ({ roomId }) =>
|
||||
cryptoApi.isEncryptionEnabledInRoom(roomId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is key backup enabled? Use a cached answer if we have one.
|
||||
*/
|
||||
private isKeyBackupUploadActive = async (logger: BaseLogger): Promise<boolean> => {
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) {
|
||||
// If there is no crypto, there is no key backup
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we've already cached the answer, return it.
|
||||
if (this.cachedKeyBackupUploadActive !== undefined) {
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
}
|
||||
|
||||
// Fetch the answer and cache it
|
||||
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
|
||||
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
|
||||
logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
|
||||
|
||||
return this.cachedKeyBackupUploadActive;
|
||||
};
|
||||
}
|
||||
39
src/device-listener/DeviceState.ts
Normal file
39
src/device-listener/DeviceState.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The state of the device and the user's account.
|
||||
*/
|
||||
type DeviceState =
|
||||
/**
|
||||
* The device is in a good state.
|
||||
*/
|
||||
| "ok"
|
||||
/**
|
||||
* The user needs to set up recovery.
|
||||
*/
|
||||
| "set_up_recovery"
|
||||
/**
|
||||
* The device is not verified.
|
||||
*/
|
||||
| "verify_this_session"
|
||||
/**
|
||||
* Key storage is out of sync (keys are missing locally, from recovery, or both).
|
||||
*/
|
||||
| "key_storage_out_of_sync"
|
||||
/**
|
||||
* Key storage is not enabled, and has not been marked as purposely disabled.
|
||||
*/
|
||||
| "turn_on_key_storage"
|
||||
/**
|
||||
* The user's identity needs resetting, due to missing keys.
|
||||
*/
|
||||
| "identity_needs_reset";
|
||||
|
||||
export default DeviceState;
|
||||
@ -161,6 +161,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
this.emit(LISTS_UPDATE_EVENT);
|
||||
}
|
||||
|
||||
protected async onNotReady(): Promise<void> {
|
||||
this.roomSkipList = undefined;
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload): Promise<void> {
|
||||
if (!this.matrixClient || !this.roomSkipList?.initialized) return;
|
||||
|
||||
|
||||
@ -11,10 +11,11 @@ import React from "react";
|
||||
import { KeyIcon, ErrorSolidIcon, SettingsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { type ComponentType } from "react";
|
||||
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import Modal from "../Modal";
|
||||
import { _t } from "../languageHandler";
|
||||
import DeviceListener, { type DeviceState } from "../DeviceListener";
|
||||
import DeviceListener from "../DeviceListener";
|
||||
import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog";
|
||||
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
|
||||
import ToastStore, { type IToast } from "../stores/ToastStore";
|
||||
@ -30,6 +31,7 @@ import ConfirmKeyStorageOffDialog from "../components/views/dialogs/ConfirmKeySt
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { resetKeyBackupAndWait } from "../utils/crypto/resetKeyBackup";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import type DeviceState from "../device-listener/DeviceState";
|
||||
|
||||
const TOAST_KEY = "setupencryption";
|
||||
|
||||
@ -143,6 +145,7 @@ const getDescription = (state: DeviceStateForToast): string => {
|
||||
* @param state The state of the device
|
||||
*/
|
||||
export const showToast = (state: DeviceStateForToast): void => {
|
||||
const myLogger = logger.getChild(`SetupEncryptionToast[${state}]:`);
|
||||
if (
|
||||
ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({
|
||||
kind: state as any,
|
||||
@ -161,6 +164,7 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
interactionType: "Pointer",
|
||||
name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick",
|
||||
});
|
||||
myLogger.debug("Primary button clicked: opening encryption settings dialog");
|
||||
// Open the user settings dialog to the encryption tab
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
@ -170,9 +174,11 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
break;
|
||||
}
|
||||
case "verify_this_session":
|
||||
myLogger.debug("Primary button clicked: opening SetupEncryptionDialog");
|
||||
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
|
||||
break;
|
||||
case "key_storage_out_of_sync": {
|
||||
myLogger.debug("Primary button clicked: starting recovery process");
|
||||
const modal = Modal.createDialog(
|
||||
Spinner,
|
||||
undefined,
|
||||
@ -214,6 +220,7 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
break;
|
||||
}
|
||||
case "identity_needs_reset": {
|
||||
myLogger.debug("Primary button clicked: opening encryption settings dialog");
|
||||
// Open the user settings dialog to reset identity
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
@ -236,6 +243,7 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
interactionType: "Pointer",
|
||||
name: "ToastSetUpRecoveryDismiss",
|
||||
});
|
||||
myLogger.debug("Secondary button clicked: disabling recovery");
|
||||
// Record that the user doesn't want to set up recovery
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
await deviceListener.recordRecoveryDisabled();
|
||||
@ -246,14 +254,14 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
// Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true);
|
||||
const props = {
|
||||
initialEncryptionState: needsCrossSigningReset ? "reset_identity_forgot" : "change_recovery_key",
|
||||
};
|
||||
myLogger.debug(`Secondary button clicked: opening encryption settings dialog with props`, props);
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
props: {
|
||||
initialEncryptionState: needsCrossSigningReset
|
||||
? "reset_identity_forgot"
|
||||
: "change_recovery_key",
|
||||
},
|
||||
props,
|
||||
};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
break;
|
||||
@ -272,6 +280,7 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
);
|
||||
const [dismissed] = await modal.finished;
|
||||
if (dismissed) {
|
||||
myLogger.debug("Secondary button clicked and confirmed: recording key storage disabled");
|
||||
const deviceListener = DeviceListener.sharedInstance();
|
||||
await deviceListener.recordKeyBackupDisabled();
|
||||
deviceListener.dismissEncryptionSetup();
|
||||
@ -279,6 +288,7 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
myLogger.debug("Secondary button clicked: dismissing");
|
||||
DeviceListener.sharedInstance().dismissEncryptionSetup();
|
||||
}
|
||||
};
|
||||
@ -292,20 +302,24 @@ export const showToast = (state: DeviceStateForToast): void => {
|
||||
*/
|
||||
const onAccessSecretStorageFailed = async (error: Error): Promise<void> => {
|
||||
if (error instanceof AccessCancelledError) {
|
||||
myLogger.debug("AccessSecretStorage failed: user cancelled");
|
||||
// The user cancelled the dialog - just allow it to close
|
||||
} else {
|
||||
// A real error happened - jump to the reset identity or change
|
||||
// recovery tab
|
||||
const needsCrossSigningReset =
|
||||
await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset(true);
|
||||
const props = {
|
||||
initialEncryptionState: needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key",
|
||||
};
|
||||
myLogger.debug(
|
||||
`AccessSecretStorage failed: ${error}. Opening encryption settings dialog with props: `,
|
||||
props,
|
||||
);
|
||||
const payload: OpenToTabPayload = {
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Encryption,
|
||||
props: {
|
||||
initialEncryptionState: needsCrossSigningReset
|
||||
? "reset_identity_sync_failed"
|
||||
: "change_recovery_key",
|
||||
},
|
||||
props,
|
||||
};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
|
||||
|
||||
import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/DeviceListener";
|
||||
import DeviceListener from "../../src/DeviceListener";
|
||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||
import * as SetupEncryptionToast from "../../src/toasts/SetupEncryptionToast";
|
||||
import * as UnverifiedSessionToast from "../../src/toasts/UnverifiedSessionToast";
|
||||
@ -37,6 +37,7 @@ import { SettingLevel } from "../../src/settings/SettingLevel";
|
||||
import { getMockClientWithEventEmitter, mockPlatformPeg } from "../test-utils";
|
||||
import { isBulkUnverifiedDeviceReminderSnoozed } from "../../src/utils/device/snoozeBulkUnverifiedDeviceReminder";
|
||||
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
|
||||
import { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../src/device-listener/DeviceListenerCurrentDevice";
|
||||
|
||||
jest.mock("../../src/dispatcher/dispatcher", () => ({
|
||||
dispatch: jest.fn(),
|
||||
|
||||
@ -1839,4 +1839,39 @@ describe("<MatrixChat />", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blacklistUnverifiedDevices settings", () => {
|
||||
beforeEach(async () => {
|
||||
mockPlatformPeg();
|
||||
getComponent({});
|
||||
// Force a client start manually to avoid needing to go through the login flow.
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ClientStarted,
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should ignore room-device-level blacklistUnverifiedDevices updates", async () => {
|
||||
// Set the blacklist toggle at a room-specific level ...
|
||||
await SettingsStore.setValue(
|
||||
"blacklistUnverifiedDevices",
|
||||
"!room:example.com",
|
||||
SettingLevel.ROOM_DEVICE,
|
||||
true,
|
||||
);
|
||||
// ... which SHOULD NOT affect the global blacklist property.
|
||||
expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeFalsy();
|
||||
}, 10e3);
|
||||
|
||||
it("should update globalBlacklistUnverifiedDevices on device-level updates", async () => {
|
||||
// Set the blacklist toggle at a device level ...
|
||||
await SettingsStore.setValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE, true);
|
||||
// shich SHOULD affect the global blacklist property.
|
||||
expect(mockClient.getCrypto()!.globalBlacklistUnverifiedDevices).toBeTruthy();
|
||||
}, 10e3);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user