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);
}
private getInitialPhase(): void {
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey();
if (keyFromCustomisations) {
logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step"); logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step");
this.recoveryKey = { this.recoveryKey = {
privateKey: keyFromCustomisations, privateKey: keyFromCustomisations,
}; };
this.bootstrapSecretStorage(); 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.state.canUploadKeysWithPasswordOnly &&
e instanceof MatrixError &&
e.httpStatus === 401 &&
e.data.flows
) {
this.setState({
accountPassword: "",
accountPasswordCorrect: false,
phase: Phase.Migrate,
});
} else {
this.setState({ error: true }); 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(),
uploadDeviceSigningKeys: jest.fn().mockImplementation(async () => {
await sleep(0); // CreateSecretStorageDialog doesn't expect this to resolve immediately await sleep(0); // CreateSecretStorageDialog doesn't expect this to resolve immediately
throw new MatrixError({ flows: [] }); 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,47 +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
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"
/>
<div>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</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 backup is present but not trusted shows migrate text, then 'RestoreKeyBackupDialog' if 'Restore' is clicked 1`] = `
<div> <div>
<div <div
data-focus-guard="true" data-focus-guard="true"
@ -185,105 +145,47 @@ exports[`CreateSecretStorageDialog when backup is present but not trusted shows
class="mx_Dialog_header" class="mx_Dialog_header"
> >
<h1 <h1
class="mx_Heading_h3 mx_Dialog_title" class="mx_Heading_h3 mx_Dialog_title mx_CreateSecretStorageDialog_titleWithIcon mx_CreateSecretStorageDialog_secureBackupTitle"
id="mx_BaseDialog_title" id="mx_BaseDialog_title"
> >
Upgrade your encryption Save your Security Key
</h1> </h1>
</div> </div>
<div> <div>
<form> <div>
<p> <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. Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.
</p> </p>
<div> <div
<div> class="mx_CreateSecretStorageDialog_primaryContainer mx_CreateSecretStorageDialog_recoveryKeyPrimarycontainer"
<div> >
Restore your key backup to upgrade your encryption <div
</div> class="mx_CreateSecretStorageDialog_recoveryKeyContainer"
</div> >
<div
class="mx_CreateSecretStorageDialog_recoveryKey"
>
<code />
</div> </div>
<div <div
class="mx_Dialog_buttons" class="mx_CreateSecretStorageDialog_recoveryKeyButtons"
> >
<span <div
class="mx_Dialog_buttons_row" class="mx_AccessibleButton mx_Dialog_primary mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
> >
<button Download
class="danger" </div>
type="button" <span>
> or
Skip
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Restore
</button>
</span> </span>
</div>
</form>
</div>
</div>
<div <div
data-focus-guard="true" class="mx_AccessibleButton mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;" role="button"
tabindex="0" 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 Copy
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> </div>
@ -294,196 +196,18 @@ exports[`CreateSecretStorageDialog when canUploadKeysWithPasswordOnly calls boot
<span <span
class="mx_Dialog_buttons_row" class="mx_Dialog_buttons_row"
> >
<button
class="danger"
type="button"
>
Skip
</button>
<button <button
class="mx_Dialog_primary" class="mx_Dialog_primary"
data-testid="dialog-primary-button" data-testid="dialog-primary-button"
disabled="" disabled=""
type="button" type="button"
> >
Next Continue
</button> </button>
</span> </span>
</div> </div>
</form>
</div> </div>
</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>
<div <div
data-focus-guard="true" data-focus-guard="true"