Remove "Upgrade your encryption" flow in CreateSecretStorageDialog (#28290)

* Remove "Upgrade your encryption" flow

* Rename and remove tests

* Remove `BackupTrustInfo`

* Get keybackup when bootstraping the secret storage.

* Update src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Florian Duros 2024-10-30 12:22:05 +01:00 committed by GitHub
parent c23c9dfacb
commit 386b782f2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 146 additions and 694 deletions

View File

@ -11,7 +11,7 @@ import React, { createRef } from "react";
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { AuthDict, CrossSigningKeys, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; import { AuthDict, CrossSigningKeys, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix";
import { CryptoEvent, BackupTrustInfo, GeneratedSecretStorageKey, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
import classNames from "classnames"; import classNames from "classnames";
import CheckmarkIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; import CheckmarkIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
@ -25,7 +25,6 @@ import StyledRadioButton from "../../../../components/views/elements/StyledRadio
import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { import {
getSecureBackupSetupMethods, getSecureBackupSetupMethods,
isSecureBackupRequired, isSecureBackupRequired,
@ -45,7 +44,6 @@ enum Phase {
Loading = "loading", Loading = "loading",
LoadError = "load_error", LoadError = "load_error",
ChooseKeyPassphrase = "choose_key_passphrase", ChooseKeyPassphrase = "choose_key_passphrase",
Migrate = "migrate",
Passphrase = "passphrase", Passphrase = "passphrase",
PassphraseConfirm = "passphrase_confirm", PassphraseConfirm = "passphrase_confirm",
ShowKey = "show_key", ShowKey = "show_key",
@ -72,24 +70,6 @@ interface IState {
downloaded: boolean; downloaded: boolean;
setPassphrase: boolean; setPassphrase: boolean;
/** Information on the current key backup version, as returned by the server.
*
* `null` could mean any of:
* * we haven't yet requested the data from the server.
* * we were unable to reach the server.
* * the server returned key backup version data we didn't understand or was malformed.
* * there is actually no backup on the server.
*/
backupInfo: KeyBackupInfo | null;
/**
* Information on whether the backup in `backupInfo` is correctly signed, and whether we have the right key to
* decrypt it.
*
* `undefined` if `backupInfo` is null, or if crypto is not enabled in the client.
*/
backupTrustInfo: BackupTrustInfo | undefined;
// does the server offer a UI auth flow with just m.login.password // does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload? // for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: boolean | null; canUploadKeysWithPasswordOnly: boolean | null;
@ -141,16 +121,17 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
this.queryKeyUploadAuth(); this.queryKeyUploadAuth();
} }
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
const phase = keyFromCustomisations ? Phase.Loading : Phase.ChooseKeyPassphrase;
this.state = { this.state = {
phase: Phase.Loading, phase,
passPhrase: "", passPhrase: "",
passPhraseValid: false, passPhraseValid: false,
passPhraseConfirm: "", passPhraseConfirm: "",
copied: false, copied: false,
downloaded: false, downloaded: false,
setPassphrase: false, setPassphrase: false,
backupInfo: null,
backupTrustInfo: undefined,
// does the server offer a UI auth flow with just m.login.password // does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload? // for /keys/device_signing/upload?
accountPasswordCorrect: null, accountPasswordCorrect: null,
@ -160,60 +141,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
accountPassword, accountPassword,
}; };
cli.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChange); if (keyFromCustomisations) this.initExtension(keyFromCustomisations);
this.getInitialPhase();
} }
public componentWillUnmount(): void { private initExtension(keyFromCustomisations: Uint8Array): void {
MatrixClientPeg.get()?.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatusChange); logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step");
} this.recoveryKey = {
privateKey: keyFromCustomisations,
private getInitialPhase(): void { };
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey(); this.bootstrapSecretStorage();
if (keyFromCustomisations) {
logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step");
this.recoveryKey = {
privateKey: keyFromCustomisations,
};
this.bootstrapSecretStorage();
return;
}
this.fetchBackupInfo();
}
/**
* Attempt to get information on the current backup from the server, and update the state.
*
* Updates {@link IState.backupInfo} and {@link IState.backupTrustInfo}, and picks an appropriate phase for
* {@link IState.phase}.
*
* @returns If the backup data was retrieved successfully, the trust info for the backup. Otherwise, undefined.
*/
private async fetchBackupInfo(): Promise<BackupTrustInfo | undefined> {
try {
const cli = MatrixClientPeg.safeGet();
const backupInfo = await cli.getKeyBackupVersion();
const backupTrustInfo =
// we may not have started crypto yet, in which case we definitely don't trust the backup
backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined;
const { forceReset } = this.props;
const phase = backupInfo && !forceReset ? Phase.Migrate : Phase.ChooseKeyPassphrase;
this.setState({
phase,
backupInfo,
backupTrustInfo,
});
return backupTrustInfo;
} catch (e) {
console.error("Error fetching backup data from server", e);
this.setState({ phase: Phase.LoadError });
return undefined;
}
} }
private async queryKeyUploadAuth(): Promise<void> { private async queryKeyUploadAuth(): Promise<void> {
@ -237,10 +173,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
} }
} }
private onKeyBackupStatusChange = (): void => {
if (this.state.phase === Phase.Migrate) this.fetchBackupInfo();
};
private onKeyPassphraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => { private onKeyPassphraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhraseKeySelected: e.target.value, passPhraseKeySelected: e.target.value,
@ -265,15 +197,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
} }
}; };
private onMigrateFormSubmit = (e: React.FormEvent): void => {
e.preventDefault();
if (this.state.backupTrustInfo?.trusted) {
this.bootstrapSecretStorage();
} else {
this.restoreBackup();
}
};
private onCopyClick = (): void => { private onCopyClick = (): void => {
const successful = copyNode(this.recoveryKeyNode.current); const successful = copyNode(this.recoveryKeyNode.current);
if (successful) { if (successful) {
@ -340,16 +263,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
}; };
private bootstrapSecretStorage = async (): Promise<void> => { private bootstrapSecretStorage = async (): Promise<void> => {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto()!;
const { forceReset } = this.props;
let backupInfo;
// First, unless we know we want to do a reset, we see if there is an existing key backup
if (!forceReset) {
try {
this.setState({ phase: Phase.Loading });
backupInfo = await cli.getKeyBackupVersion();
} catch (e) {
logger.error("Error fetching backup data from server", e);
this.setState({ phase: Phase.LoadError });
return;
}
}
this.setState({ this.setState({
phase: Phase.Storing, phase: Phase.Storing,
error: undefined, error: undefined,
}); });
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto()!;
const { forceReset } = this.props;
try { try {
if (forceReset) { if (forceReset) {
logger.log("Forcing secret storage reset"); logger.log("Forcing secret storage reset");
@ -371,8 +306,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
}); });
await crypto.bootstrapSecretStorage({ await crypto.bootstrapSecretStorage({
createSecretStorageKey: async () => this.recoveryKey!, createSecretStorageKey: async () => this.recoveryKey!,
keyBackupInfo: this.state.backupInfo!, setupNewKeyBackup: !backupInfo,
setupNewKeyBackup: !this.state.backupInfo,
}); });
} }
await initialiseDehydration(true); await initialiseDehydration(true);
@ -381,20 +315,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
phase: Phase.Stored, phase: Phase.Stored,
}); });
} catch (e) { } catch (e) {
if ( this.setState({ error: true });
this.state.canUploadKeysWithPasswordOnly &&
e instanceof MatrixError &&
e.httpStatus === 401 &&
e.data.flows
) {
this.setState({
accountPassword: "",
accountPasswordCorrect: false,
phase: Phase.Migrate,
});
} else {
this.setState({ error: true });
}
logger.error("Error bootstrapping secret storage", e); logger.error("Error bootstrapping secret storage", e);
} }
}; };
@ -403,27 +324,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
this.props.onFinished(false); this.props.onFinished(false);
}; };
private restoreBackup = async (): Promise<void> => {
const { finished } = Modal.createDialog(
RestoreKeyBackupDialog,
{
showSummary: false,
},
undefined,
/* priority = */ false,
/* static = */ false,
);
await finished;
const backupTrustInfo = await this.fetchBackupInfo();
if (backupTrustInfo?.trusted && this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
this.bootstrapSecretStorage();
}
};
private onLoadRetryClick = (): void => { private onLoadRetryClick = (): void => {
this.setState({ phase: Phase.Loading }); this.bootstrapSecretStorage();
this.fetchBackupInfo();
}; };
private onShowKeyContinueClick = (): void => { private onShowKeyContinueClick = (): void => {
@ -495,12 +397,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
}); });
}; };
private onAccountPasswordChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
accountPassword: e.target.value,
});
};
private renderOptionKey(): JSX.Element { private renderOptionKey(): JSX.Element {
return ( return (
<StyledRadioButton <StyledRadioButton
@ -565,55 +461,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
); );
} }
private renderPhaseMigrate(): JSX.Element {
let authPrompt;
let nextCaption = _t("action|next");
if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = (
<div>
<div>{_t("settings|key_backup|setup_secure_backup|requires_password_confirmation")}</div>
<div>
<Field
id="mx_CreateSecretStorageDialog_password"
type="password"
label={_t("common|password")}
value={this.state.accountPassword}
onChange={this.onAccountPasswordChange}
forceValidity={this.state.accountPasswordCorrect === false ? false : undefined}
autoFocus={true}
/>
</div>
</div>
);
} else if (!this.state.backupTrustInfo?.trusted) {
authPrompt = (
<div>
<div>{_t("settings|key_backup|setup_secure_backup|requires_key_restore")}</div>
</div>
);
nextCaption = _t("action|restore");
} else {
authPrompt = <p>{_t("settings|key_backup|setup_secure_backup|requires_server_authentication")}</p>;
}
return (
<form onSubmit={this.onMigrateFormSubmit}>
<p>{_t("settings|key_backup|setup_secure_backup|session_upgrade_description")}</p>
<div>{authPrompt}</div>
<DialogButtons
primaryButton={nextCaption}
onPrimaryButtonClick={this.onMigrateFormSubmit}
hasCancel={false}
primaryDisabled={!!this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
>
<button type="button" className="danger" onClick={this.onCancelClick}>
{_t("action|skip")}
</button>
</DialogButtons>
</form>
);
}
private renderPhasePassPhrase(): JSX.Element { private renderPhasePassPhrase(): JSX.Element {
return ( return (
<form onSubmit={this.onPassPhraseNextClick}> <form onSubmit={this.onPassPhraseNextClick}>
@ -829,8 +676,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
switch (phase) { switch (phase) {
case Phase.ChooseKeyPassphrase: case Phase.ChooseKeyPassphrase:
return _t("encryption|set_up_toast_title"); return _t("encryption|set_up_toast_title");
case Phase.Migrate:
return _t("settings|key_backup|setup_secure_backup|title_upgrade_encryption");
case Phase.Passphrase: case Phase.Passphrase:
return _t("settings|key_backup|setup_secure_backup|title_set_phrase"); return _t("settings|key_backup|setup_secure_backup|title_set_phrase");
case Phase.PassphraseConfirm: case Phase.PassphraseConfirm:
@ -889,9 +734,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
case Phase.ChooseKeyPassphrase: case Phase.ChooseKeyPassphrase:
content = this.renderPhaseChooseKeyPassphrase(); content = this.renderPhaseChooseKeyPassphrase();
break; break;
case Phase.Migrate:
content = this.renderPhaseMigrate();
break;
case Phase.Passphrase: case Phase.Passphrase:
content = this.renderPhasePassPhrase(); content = this.renderPhasePassPhrase();
break; break;

View File

@ -103,7 +103,6 @@
"report_content": "Report Content", "report_content": "Report Content",
"resend": "Resend", "resend": "Resend",
"reset": "Reset", "reset": "Reset",
"restore": "Restore",
"resume": "Resume", "resume": "Resume",
"retry": "Retry", "retry": "Retry",
"review": "Review", "review": "Review",
@ -2587,18 +2586,13 @@
"pass_phrase_match_failed": "That doesn't match.", "pass_phrase_match_failed": "That doesn't match.",
"pass_phrase_match_success": "That matches!", "pass_phrase_match_success": "That matches!",
"phrase_strong_enough": "Great! This Security Phrase looks strong enough.", "phrase_strong_enough": "Great! This Security Phrase looks strong enough.",
"requires_key_restore": "Restore your key backup to upgrade your encryption",
"requires_password_confirmation": "Enter your account password to confirm the upgrade:",
"requires_server_authentication": "You'll need to authenticate with the server to confirm the upgrade.",
"secret_storage_query_failure": "Unable to query secret storage status", "secret_storage_query_failure": "Unable to query secret storage status",
"security_key_safety_reminder": "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.", "security_key_safety_reminder": "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.",
"session_upgrade_description": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.",
"set_phrase_again": "Go back to set it again.", "set_phrase_again": "Go back to set it again.",
"settings_reminder": "You can also set up Secure Backup & manage your keys in Settings.", "settings_reminder": "You can also set up Secure Backup & manage your keys in Settings.",
"title_confirm_phrase": "Confirm Security Phrase", "title_confirm_phrase": "Confirm Security Phrase",
"title_save_key": "Save your Security Key", "title_save_key": "Save your Security Key",
"title_set_phrase": "Set a Security Phrase", "title_set_phrase": "Set a Security Phrase",
"title_upgrade_encryption": "Upgrade your encryption",
"unable_to_setup": "Unable to set up secret storage", "unable_to_setup": "Unable to set up secret storage",
"use_different_passphrase": "Use a different passphrase?", "use_different_passphrase": "Use a different passphrase?",
"use_phrase_only_you_know": "Use a secret phrase only you know, and optionally save a Security Key to use for backup." "use_phrase_only_you_know": "Use a secret phrase only you know, and optionally save a Security Key to use for backup."

View File

@ -127,6 +127,10 @@ export function createTestClient(): MatrixClient {
prepareToEncrypt: jest.fn(), prepareToEncrypt: jest.fn(),
bootstrapCrossSigning: jest.fn(), bootstrapCrossSigning: jest.fn(),
getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null), getActiveSessionBackupVersion: jest.fn().mockResolvedValue(null),
isKeyBackupTrusted: jest.fn().mockResolvedValue({}),
createRecoveryKeyFromPassphrase: jest.fn().mockResolvedValue({}),
bootstrapSecretStorage: jest.fn(),
isDehydrationSupported: jest.fn().mockResolvedValue(false),
}), }),
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),
@ -270,6 +274,7 @@ export function createTestClient(): MatrixClient {
getOrCreateFilter: jest.fn(), getOrCreateFilter: jest.fn(),
sendStickerMessage: jest.fn(), sendStickerMessage: jest.fn(),
getLocalAliases: jest.fn().mockReturnValue([]), getLocalAliases: jest.fn().mockReturnValue([]),
uploadDeviceSigningKeys: jest.fn(),
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);

View File

@ -10,42 +10,23 @@ import { render, RenderResult, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import React from "react"; import React from "react";
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { Crypto, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { defer, IDeferred, sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { import { filterConsole, stubClient } from "../../../../../test-utils";
filterConsole,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsServer,
} from "../../../../../test-utils";
import CreateSecretStorageDialog from "../../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog"; import CreateSecretStorageDialog from "../../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog";
import Modal from "../../../../../../src/Modal";
import RestoreKeyBackupDialog from "../../../../../../src/components/views/dialogs/security/RestoreKeyBackupDialog";
describe("CreateSecretStorageDialog", () => { describe("CreateSecretStorageDialog", () => {
let mockClient: MockedObject<MatrixClient>; let mockClient: MockedObject<MatrixClient>;
let mockCrypto: MockedObject<Crypto.CryptoApi>;
beforeEach(() => { beforeEach(() => {
mockClient = getMockClientWithEventEmitter({ mockClient = mocked(stubClient());
...mockClientMethodsServer(), mockClient.uploadDeviceSigningKeys.mockImplementation(async () => {
...mockClientMethodsCrypto(), await sleep(0); // CreateSecretStorageDialog doesn't expect this to resolve immediately
uploadDeviceSigningKeys: jest.fn().mockImplementation(async () => { throw new MatrixError({ flows: [] });
await sleep(0); // CreateSecretStorageDialog doesn't expect this to resolve immediately
throw new MatrixError({ flows: [] });
}),
});
mockCrypto = mocked(mockClient.getCrypto()!);
Object.assign(mockCrypto, {
isKeyBackupTrusted: jest.fn(),
isDehydrationSupported: jest.fn(() => false),
bootstrapCrossSigning: jest.fn(),
bootstrapSecretStorage: jest.fn(),
}); });
// Mock the clipboard API
document.execCommand = jest.fn().mockReturnValue(true);
}); });
afterEach(() => { afterEach(() => {
@ -59,11 +40,37 @@ describe("CreateSecretStorageDialog", () => {
return render(<CreateSecretStorageDialog onFinished={onFinished} {...props} />); return render(<CreateSecretStorageDialog onFinished={onFinished} {...props} />);
} }
it("shows a loading spinner initially", async () => { it("handles the happy path", async () => {
const { container } = renderComponent(); const result = renderComponent();
expect(screen.getByTestId("spinner")).toBeDefined(); await result.findByText(
expect(container).toMatchSnapshot(); "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.",
await flushPromises(); );
expect(result.container).toMatchSnapshot();
await userEvent.click(result.getByRole("button", { name: "Continue" }));
await screen.findByText("Save your Security Key");
expect(result.container).toMatchSnapshot();
// Copy the key to enable the continue button
await userEvent.click(screen.getByRole("button", { name: "Copy" }));
expect(result.queryByText("Copied!")).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("Your keys are now being backed up from this device.");
});
it("when there is an error when bootstraping the secret storage, it shows an error", async () => {
jest.spyOn(mockClient.getCrypto()!, "bootstrapSecretStorage").mockRejectedValue(new Error("error"));
renderComponent();
await screen.findByText(
"Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.",
);
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("Save your Security Key");
await userEvent.click(screen.getByRole("button", { name: "Copy" }));
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("Unable to set up secret storage");
}); });
describe("when there is an error fetching the backup version", () => { describe("when there is an error fetching the backup version", () => {
@ -75,139 +82,19 @@ describe("CreateSecretStorageDialog", () => {
}); });
const result = renderComponent(); const result = renderComponent();
// We go though the dialog until we have to get the key backup
await userEvent.click(result.getByRole("button", { name: "Continue" }));
await userEvent.click(screen.getByRole("button", { name: "Copy" }));
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
// XXX the error message is... misleading. // XXX the error message is... misleading.
await result.findByText("Unable to query secret storage status"); await screen.findByText("Unable to query secret storage status");
expect(result.container).toMatchSnapshot();
});
});
it("shows 'Generate a Security Key' text if no key backup is present", async () => {
const result = renderComponent();
await flushPromises();
expect(result.container).toMatchSnapshot();
result.getByText("Generate a Security Key");
});
describe("when canUploadKeysWithPasswordOnly", () => {
// spy on Modal.createDialog
let modalSpy: jest.SpyInstance;
// deferred which should be resolved to indicate that the created dialog has completed
let restoreDialogFinishedDefer: IDeferred<[done?: boolean]>;
beforeEach(() => {
mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo);
mockClient.uploadDeviceSigningKeys.mockImplementation(async () => {
await sleep(0);
throw new MatrixError({
flows: [{ stages: ["m.login.password"] }],
});
});
restoreDialogFinishedDefer = defer<[done?: boolean]>();
modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: restoreDialogFinishedDefer.promise,
close: jest.fn(),
});
});
it("prompts for a password and then shows RestoreKeyBackupDialog", async () => {
const result = renderComponent();
await result.findByText(/Enter your account password to confirm the upgrade/);
expect(result.container).toMatchSnapshot(); expect(result.container).toMatchSnapshot();
await userEvent.type(result.getByPlaceholderText("Password"), "my pass"); // Now we can get the backup and we retry
result.getByRole("button", { name: "Next" }).click(); mockClient.getKeyBackupVersion.mockRestore();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(modalSpy).toHaveBeenCalledWith( await screen.findByText("Your keys are now being backed up from this device.");
RestoreKeyBackupDialog,
{
showSummary: false,
},
undefined,
false,
false,
);
restoreDialogFinishedDefer.resolve([]);
});
it("calls bootstrapSecretStorage once keys are restored if the backup is now trusted", async () => {
const result = renderComponent();
await result.findByText(/Enter your account password to confirm the upgrade/);
expect(result.container).toMatchSnapshot();
await userEvent.type(result.getByPlaceholderText("Password"), "my pass");
result.getByRole("button", { name: "Next" }).click();
expect(modalSpy).toHaveBeenCalled();
// While we restore the key backup, its signature becomes accepted
mockCrypto.isKeyBackupTrusted.mockResolvedValue({ trusted: true } as BackupTrustInfo);
restoreDialogFinishedDefer.resolve([]);
await flushPromises();
// XXX no idea why this is a sensible thing to do. I just work here.
expect(mockCrypto.bootstrapCrossSigning).toHaveBeenCalled();
expect(mockCrypto.bootstrapSecretStorage).toHaveBeenCalled();
await result.findByText("Your keys are now being backed up from this device.");
});
describe("when there is an error fetching the backup version after RestoreKeyBackupDialog", () => {
filterConsole("Error fetching backup data from server");
it("handles the error sensibly", async () => {
const result = renderComponent();
await result.findByText(/Enter your account password to confirm the upgrade/);
expect(result.container).toMatchSnapshot();
await userEvent.type(result.getByPlaceholderText("Password"), "my pass");
result.getByRole("button", { name: "Next" }).click();
expect(modalSpy).toHaveBeenCalled();
mockClient.getKeyBackupVersion.mockImplementation(async () => {
throw new Error("bleh bleh");
});
restoreDialogFinishedDefer.resolve([]);
await result.findByText("Unable to query secret storage status");
});
});
});
describe("when backup is present but not trusted", () => {
beforeEach(() => {
mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo);
});
it("shows migrate text, then 'RestoreKeyBackupDialog' if 'Restore' is clicked", async () => {
const result = renderComponent();
await result.findByText("Restore your key backup to upgrade your encryption");
expect(result.container).toMatchSnapshot();
// before we click "Restore", set up a spy on createDialog
const restoreDialogFinishedDefer = defer<[done?: boolean]>();
const modalSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: restoreDialogFinishedDefer.promise,
close: jest.fn(),
});
result.getByRole("button", { name: "Restore" }).click();
expect(modalSpy).toHaveBeenCalledWith(
RestoreKeyBackupDialog,
{
showSummary: false,
},
undefined,
false,
false,
);
// simulate RestoreKeyBackupDialog completing, to run that code path
restoreDialogFinishedDefer.resolve([]);
}); });
}); });
}); });

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreateSecretStorageDialog shows 'Generate a Security Key' text if no key backup is present 1`] = ` exports[`CreateSecretStorageDialog handles the happy path 1`] = `
<div> <div>
<div <div
data-focus-guard="true" data-focus-guard="true"
@ -128,7 +128,7 @@ exports[`CreateSecretStorageDialog shows 'Generate a Security Key' text if no ke
</div> </div>
`; `;
exports[`CreateSecretStorageDialog shows a loading spinner initially 1`] = ` exports[`CreateSecretStorageDialog handles the happy path 2`] = `
<div> <div>
<div <div
data-focus-guard="true" data-focus-guard="true"
@ -143,19 +143,68 @@ exports[`CreateSecretStorageDialog shows a loading spinner initially 1`] = `
> >
<div <div
class="mx_Dialog_header" class="mx_Dialog_header"
/> >
<h1
class="mx_Heading_h3 mx_Dialog_title mx_CreateSecretStorageDialog_titleWithIcon mx_CreateSecretStorageDialog_secureBackupTitle"
id="mx_BaseDialog_title"
>
Save your Security Key
</h1>
</div>
<div> <div>
<div> <div>
<p>
Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.
</p>
<div <div
class="mx_Spinner" class="mx_CreateSecretStorageDialog_primaryContainer mx_CreateSecretStorageDialog_recoveryKeyPrimarycontainer"
> >
<div <div
aria-label="Loading…" class="mx_CreateSecretStorageDialog_recoveryKeyContainer"
class="mx_Spinner_icon" >
data-testid="spinner" <div
role="progressbar" class="mx_CreateSecretStorageDialog_recoveryKey"
style="width: 32px; height: 32px;" >
/> <code />
</div>
<div
class="mx_CreateSecretStorageDialog_recoveryKeyButtons"
>
<div
class="mx_AccessibleButton mx_Dialog_primary mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Download
</div>
<span>
or
</span>
<div
class="mx_AccessibleButton mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Copy
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Continue
</button>
</span>
</div> </div>
</div> </div>
</div> </div>
@ -168,331 +217,6 @@ exports[`CreateSecretStorageDialog shows a loading spinner initially 1`] = `
</div> </div>
`; `;
exports[`CreateSecretStorageDialog when backup is present but not trusted shows migrate text, then 'RestoreKeyBackupDialog' if 'Restore' is clicked 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Upgrade your encryption
</h1>
</div>
<div>
<form>
<p>
Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.
</p>
<div>
<div>
<div>
Restore your key backup to upgrade your encryption
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
class="danger"
type="button"
>
Skip
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Restore
</button>
</span>
</div>
</form>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`CreateSecretStorageDialog when canUploadKeysWithPasswordOnly calls bootstrapSecretStorage once keys are restored if the backup is now trusted 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Upgrade your encryption
</h1>
</div>
<div>
<form>
<p>
Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.
</p>
<div>
<div>
<div>
Enter your account password to confirm the upgrade:
</div>
<div>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_CreateSecretStorageDialog_password"
label="Password"
placeholder="Password"
type="password"
value=""
/>
<label
for="mx_CreateSecretStorageDialog_password"
>
Password
</label>
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
class="danger"
type="button"
>
Skip
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Next
</button>
</span>
</div>
</form>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`CreateSecretStorageDialog when canUploadKeysWithPasswordOnly prompts for a password and then shows RestoreKeyBackupDialog 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Upgrade your encryption
</h1>
</div>
<div>
<form>
<p>
Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.
</p>
<div>
<div>
<div>
Enter your account password to confirm the upgrade:
</div>
<div>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_CreateSecretStorageDialog_password"
label="Password"
placeholder="Password"
type="password"
value=""
/>
<label
for="mx_CreateSecretStorageDialog_password"
>
Password
</label>
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
class="danger"
type="button"
>
Skip
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Next
</button>
</span>
</div>
</form>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`CreateSecretStorageDialog when canUploadKeysWithPasswordOnly when there is an error fetching the backup version after RestoreKeyBackupDialog handles the error sensibly 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_CreateSecretStorageDialog"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Upgrade your encryption
</h1>
</div>
<div>
<form>
<p>
Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.
</p>
<div>
<div>
<div>
Enter your account password to confirm the upgrade:
</div>
<div>
<div
class="mx_Field mx_Field_input"
>
<input
id="mx_CreateSecretStorageDialog_password"
label="Password"
placeholder="Password"
type="password"
value=""
/>
<label
for="mx_CreateSecretStorageDialog_password"
>
Password
</label>
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
class="danger"
type="button"
>
Skip
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
disabled=""
type="button"
>
Next
</button>
</span>
</div>
</form>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`CreateSecretStorageDialog when there is an error fetching the backup version shows an error 1`] = ` exports[`CreateSecretStorageDialog when there is an error fetching the backup version shows an error 1`] = `
<div> <div>
<div <div