Handle cross-signing keys missing locally and/or from secret storage (#31367)

* show correct toast when cross-signing keys missing

If cross-signing keys are missing both locally and in 4S, show a new toast
saying that identity needs resetting, rather than saying that the device
needs to be verified.

* refactor: make DeviceListener in charge of device state

- move enum from SetupEncryptionToast to DeviceListener
- DeviceListener has public method to get device state
- DeviceListener emits events to update device state

* reset key backup when needed in RecoveryPanelOutOfSync

brings RecoveryPanelOutOfSync in line with SetupEncryptionToast behaviour

* update strings to agree with designs from Figma

* use DeviceListener to determine EncryptionUserSettingsTab display

rather than using its own logic

* prompt to reset identity in Encryption Settings when needed

* fix type

* calculate device state even if we aren't going to show a toast

* update snapshot

* make logs more accurate

* add tests

* make the bot use a different access token/device

* only log in a new session when requested

* Mark properties as read-only

Co-authored-by: Skye Elliot <actuallyori@gmail.com>

* remove some duplicate strings

* make accessToken optional instead of using empty string

* switch from enum to string union as per review

* apply other changes from review

* handle errors in accessSecretStorage

* remove incorrect testid

---------

Co-authored-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Hubert Chathi
2025-12-19 12:00:50 -05:00
committed by GitHub
parent ce9c66ba4c
commit ebd5df633e
14 changed files with 668 additions and 343 deletions

View File

@@ -9,19 +9,45 @@ import React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync";
import { accessSecretStorage } from "../../../../../../src/SecurityManager";
import { AccessCancelledError, accessSecretStorage } from "../../../../../../src/SecurityManager";
import DeviceListener from "../../../../../../src/DeviceListener";
import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
jest.mock("../../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));
jest.mock("../../../../../../src/SecurityManager", () => {
const originalModule = jest.requireActual("../../../../../../src/SecurityManager");
return {
...originalModule,
accessSecretStorage: jest.fn(),
};
});
describe("<RecoveyPanelOutOfSync />", () => {
function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) {
return render(<RecoveryPanelOutOfSync onFinish={onFinish} onForgotRecoveryKey={onForgotRecoveryKey} />);
let matrixClient: MatrixClient;
function renderComponent(
onFinish = jest.fn(),
onForgotRecoveryKey = jest.fn(),
onAccessSecretStorageFailed = jest.fn(),
) {
matrixClient = createTestClient();
return render(
<RecoveryPanelOutOfSync
onFinish={onFinish}
onForgotRecoveryKey={onForgotRecoveryKey}
onAccessSecretStorageFailed={onAccessSecretStorageFailed}
/>,
withClientContextRenderOptions(matrixClient),
);
}
afterEach(() => {
jest.clearAllMocks();
});
it("should render", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
@@ -38,8 +64,12 @@ describe("<RecoveyPanelOutOfSync />", () => {
});
it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false);
const user = userEvent.setup();
mocked(accessSecretStorage).mockClear().mockResolvedValue();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
return await func();
});
const onFinish = jest.fn();
renderComponent(onFinish);
@@ -47,5 +77,59 @@ describe("<RecoveyPanelOutOfSync />", () => {
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled();
});
it("should reset key backup if needed", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
return await func();
});
const onFinish = jest.fn();
renderComponent(onFinish);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled();
});
it("should call onAccessSecretStorageFailed on failure", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
throw new Error("Error");
});
const onAccessSecretStorageFailed = jest.fn();
renderComponent(jest.fn(), jest.fn(), onAccessSecretStorageFailed);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onAccessSecretStorageFailed).toHaveBeenCalled();
});
it("should not call onAccessSecretStorageFailed when cancelled", async () => {
jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true);
const user = userEvent.setup();
mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise<void> => {}) => {
throw new AccessCancelledError();
});
const onFinish = jest.fn();
const onAccessSecretStorageFailed = jest.fn();
renderComponent(onFinish, jest.fn(), onAccessSecretStorageFailed);
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
expect(onAccessSecretStorageFailed).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import React from "react";
import { act, render, screen } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
@@ -18,6 +19,7 @@ import {
} from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
import Modal from "../../../../../../../src/Modal";
import DeviceListener from "../../../../../../../src/DeviceListener";
describe("<EncryptionUserSettingsTab />", () => {
let matrixClient: MatrixClient;
@@ -37,22 +39,21 @@ describe("<EncryptionUserSettingsTab />", () => {
userSigningKey: true,
},
});
jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok");
});
afterEach(() => {
jest.resetAllMocks();
});
function renderComponent(props: { initialState?: State } = {}) {
return render(<EncryptionUserSettingsTab {...props} />, withClientContextRenderOptions(matrixClient));
}
it("should display a loading state when the encryption state is computed", () => {
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockImplementation(() => new Promise(() => {}));
renderComponent();
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
});
it("should display a verify button when the encryption is not set up", async () => {
const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false);
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("verify_this_session");
const { asFragment } = renderComponent();
await waitFor(() =>
@@ -81,17 +82,7 @@ describe("<EncryptionUserSettingsTab />", () => {
});
it("should display the recovery out of sync panel when secrets are not cached", async () => {
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
// Secrets are not cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
const user = userEvent.setup();
const { asFragment } = renderComponent();
@@ -196,18 +187,7 @@ describe("<EncryptionUserSettingsTab />", () => {
it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => {
const user = userEvent.setup();
jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1");
// Secrets are not cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
privateKeysCachedLocally: {
masterKey: false,
selfSigningKey: true,
userSigningKey: true,
},
});
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync");
renderComponent({ initialState: "reset_identity_forgot" });
@@ -220,4 +200,17 @@ describe("<EncryptionUserSettingsTab />", () => {
screen.getByText("Your key storage is out of sync. Click one of the buttons below to fix the problem."),
);
});
it("should display the identity needs reset panel when the user's identity needs resetting", async () => {
mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("identity_needs_reset");
const user = userEvent.setup();
const { asFragment } = renderComponent();
await waitFor(() => screen.getByRole("button", { name: "Continue with reset" }));
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Continue with reset" }));
expect(screen.getByRole("heading", { name: "You need to reset your identity" })).toBeVisible();
});
});

View File

@@ -81,6 +81,64 @@ exports[`<EncryptionUserSettingsTab /> should display the change recovery key pa
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the identity needs reset panel when the user's identity needs resetting 1`] = `
<DocumentFragment>
<div
class="mx_SettingsTab mx_EncryptionUserSettingsTab"
data-testid="encryptionTab"
>
<div
class="mx_SettingsTab_sections"
>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 mx_SettingsHeader"
>
Your key storage is out of sync.
</h2>
<div
class="mx_SettingsSubheader"
>
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
/>
</svg>
You have to reset your cryptographic identity in order to ensure access to your message history
</span>
</div>
</div>
<div>
<button
class="_button_187yx_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Continue with reset
</button>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<EncryptionUserSettingsTab /> should display the recovery out of sync panel when secrets are not cached 1`] = `
<DocumentFragment>
<div