Skip to content

Commit

Permalink
Use matrixClient.secretStorage.getDefaultKeyId instead of `matrixCl…
Browse files Browse the repository at this point in the history
…ient.getCrypto().checkKeyBackupAndEnable` to know if we need to set up a recovery key
  • Loading branch information
florianduros committed Jan 9, 2025
1 parent 7af44cc commit 380ef62
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 28 deletions.
10 changes: 5 additions & 5 deletions src/components/views/settings/encryption/ChangeRecoveryKey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface ChangeRecoveryKeyProps {
* If true, the component will display the flow to change the recovery key.
* If false,the component will display the flow to set up a new recovery key.
*/
userHasKeyBackup: boolean;
userHasRecoveryKey: boolean;
/**
* Called when the recovery key is successfully changed.
*/
Expand All @@ -56,15 +56,15 @@ interface ChangeRecoveryKeyProps {
* A component to set up or change the recovery key.
*/
export function ChangeRecoveryKey({
userHasKeyBackup,
userHasRecoveryKey,
onFinish,
onCancelClick,
}: ChangeRecoveryKeyProps): JSX.Element | null {
const matrixClient = useMatrixClientContext();

// If the user is setting up recovery for the first time, we first show them a panel explaining what
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
const [state, setState] = useState<State>(userHasKeyBackup ? "save_key_change_flow" : "inform_user");
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");

// We create a new recovery key, the recovery key will be displayed to the user
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
Expand Down Expand Up @@ -110,7 +110,7 @@ export function ChangeRecoveryKey({
// when we will try to access the secret storage during the bootstrap
await withSecretStorageKeyCache(() =>
crypto.bootstrapSecretStorage({
setupNewKeyBackup: !userHasKeyBackup,
setupNewKeyBackup: !userHasRecoveryKey,
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
}),
Expand All @@ -126,7 +126,7 @@ export function ChangeRecoveryKey({

const pages = [
_t("settings|encryption|title"),
userHasKeyBackup
userHasRecoveryKey
? _t("settings|encryption|recovery|change_recovery_key")
: _t("settings|encryption|recovery|set_up_recovery"),
];
Expand Down
19 changes: 11 additions & 8 deletions src/components/views/settings/encryption/RecoveryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import { SettingsSubheader } from "../SettingsSubheader";

/**
* The possible states of the recovery panel.
* - `loading`: We are checking the backup, the recovery and the secrets.
* - `missing_backup`: The user has no backup.
* - `loading`: We are checking the recovery key and the secrets.
* - `missing_recovery_key`: The user has no recovery key.
* - `secrets_not_cached`: The user has a backup but the secrets are not cached.
* This shouldn't happen but we have seen cases where the secrets gossiping failed or shared partial secrets when verified with another device.
* - `good`: The user has a backup and the secrets are cached.
*/
type State = "loading" | "missing_backup" | "secrets_not_cached" | "good";
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";

interface RecoveryPanelProps {
/**
Expand All @@ -38,16 +38,16 @@ interface RecoveryPanelProps {
*/
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
const [state, setState] = useState<State>("loading");
const isMissingBackup = state === "missing_backup";
const isMissingRecoveryKey = state === "missing_recovery_key";

const matrixClient = useMatrixClientContext();

const checkEncryption = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;

// Check if the user has a backup
const hasBackup = Boolean(await crypto.checkKeyBackupAndEnable());
if (!hasBackup) return setState("missing_backup");
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (!hasRecoveryKey) return setState("missing_recovery_key");

// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
Expand All @@ -66,7 +66,7 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "missing_backup":
case "missing_recovery_key":
content = (
<Button size="sm" kind="primary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(true)}>
{_t("settings|encryption|recovery|set_up_recovery")}
Expand Down Expand Up @@ -97,7 +97,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
<SettingsSection
legacy={false}
heading={
<SettingsHeader hasRecommendedTag={isMissingBackup} label={_t("settings|encryption|recovery|title")} />
<SettingsHeader
hasRecommendedTag={isMissingRecoveryKey}
label={_t("settings|encryption|recovery|title")}
/>
}
subHeading={<Subheader state={state} />}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
case "set_recovery_key":
content = (
<ChangeRecoveryKey
userHasKeyBackup={state === "change_recovery_key"}
userHasRecoveryKey={state === "change_recovery_key"}
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
Expand Down
1 change: 1 addition & 0 deletions test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function createTestClient(): MatrixClient {
isStored: jest.fn().mockReturnValue(false),
checkKey: jest.fn().mockResolvedValue(false),
hasKey: jest.fn().mockReturnValue(false),
getDefaultKeyId: jest.fn().mockResolvedValue(null),
},

store: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ describe("<ChangeRecoveryKey />", () => {
matrixClient = createTestClient();
});

function renderComponent(userHasKeyBackup = true, onFinish = jest.fn(), onCancelClick = jest.fn()) {
function renderComponent(userHasRecoveryKey = true, onFinish = jest.fn(), onCancelClick = jest.fn()) {
return render(
<ChangeRecoveryKey userHasKeyBackup={userHasKeyBackup} onFinish={onFinish} onCancelClick={onCancelClick} />,
<ChangeRecoveryKey
userHasRecoveryKey={userHasRecoveryKey}
onFinish={onFinish}
onCancelClick={onCancelClick}
/>,
withClientContextRenderOptions(matrixClient),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { render, screen } from "jest-matrix-react";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { KeyBackupCheck } from "matrix-js-sdk/src/crypto-api";

import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { RecoveryPanel } from "../../../../../../src/components/views/settings/encryption/RecoveryPanel";
Expand All @@ -36,17 +35,15 @@ describe("<RecoveryPanel />", () => {
);
}

it("should be in loading state when checking backup and the cached keys", () => {
jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockImplementation(
() => new Promise(() => {}),
);
it("should be in loading state when checking the recovery key and the cached keys", () => {
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockImplementation(() => new Promise(() => {}));

const { asFragment } = renderRecoverPanel();
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});

it("should ask to set up a recovery key when there is no key backup", async () => {
it("should ask to set up a recovery key when there is no recovery key", async () => {
const user = userEvent.setup();

const onChangeRecoveryKeyClick = jest.fn();
Expand All @@ -60,7 +57,7 @@ describe("<RecoveryPanel />", () => {
});

it("should ask to enter the recovery key when secrets are not cached", async () => {
jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck);
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
const user = userEvent.setup();
const { asFragment } = renderRecoverPanel();

Expand All @@ -72,7 +69,7 @@ describe("<RecoveryPanel />", () => {
});

it("should allow to change the recovery key when everything is good", async () => {
jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck);
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
publicKeysOnDevice: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,49 @@ exports[`<RecoveryPanel /> should ask to set up a recovery key when there is no
</DocumentFragment>
`;

exports[`<RecoveryPanel /> should ask to set up a recovery key when there is no recovery key 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
<span>
Recommended
</span>
</h2>
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
</div>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
/>
</svg>
Set up recovery
</button>
</div>
</DocumentFragment>
`;

exports[`<RecoveryPanel /> should be in loading state when checking backup and the cached keys 1`] = `
<DocumentFragment>
<div
Expand Down Expand Up @@ -177,3 +220,38 @@ exports[`<RecoveryPanel /> should be in loading state when checking backup and t
</div>
</DocumentFragment>
`;

exports[`<RecoveryPanel /> should be in loading state when checking the recovery key and the cached keys 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
</h2>
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
</div>
<svg
aria-label="Loading…"
class="_icon_1ye7b_27"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2Z"
fill-rule="evenodd"
/>
</svg>
</div>
</DocumentFragment>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { render, screen } from "jest-matrix-react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { KeyBackupCheck } from "matrix-js-sdk/src/crypto-api";

import { EncryptionUserSettingsTab } from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
Expand All @@ -22,8 +21,8 @@ describe("<EncryptionUserSettingsTab />", () => {
beforeEach(() => {
matrixClient = createTestClient();
jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(true);
// Key backup is enabled
jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue({} as KeyBackupCheck);
// Recovery key is available
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue("default key");
// Secrets are cached
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
privateKeysInSecretStorage: true,
Expand Down Expand Up @@ -83,7 +82,7 @@ describe("<EncryptionUserSettingsTab />", () => {
});

it("should display the set up recovery key when the user clicks on the set up recovery key button", async () => {
jest.spyOn(matrixClient.getCrypto()!, "checkKeyBackupAndEnable").mockResolvedValue(null);
jest.spyOn(matrixClient.secretStorage, "getDefaultKeyId").mockResolvedValue(null);
const user = userEvent.setup();

const { asFragment } = renderComponent();
Expand Down

0 comments on commit 380ef62

Please sign in to comment.