Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reset cross-signing before backup when resetting both #28402

Merged
merged 5 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/SecurityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
}
}

export interface AccessSecretStorageOpts {
/** Reset secret storage even if it's already set up. */
forceReset?: boolean;
/** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
resetCrossSigning?: boolean;
/** The cached account password, if available. */
accountPassword?: string;
}

/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
Expand All @@ -205,14 +214,17 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [forceReset] Reset secret storage even if it's already set up
* @param [opts] The options to use when accessing secret storage.
*/
export async function accessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> {
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset));
export async function accessSecretStorage(
func = async (): Promise<void> => {},
opts: AccessSecretStorageOpts = {},
): Promise<void> {
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts));
}

/** Helper for {@link #accessSecretStorage} */
async function doAccessSecretStorage(func: () => Promise<void>, forceReset: boolean): Promise<void> {
async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecretStorageOpts): Promise<void> {
try {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto();
Expand All @@ -221,7 +233,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
}

let createNew = false;
if (forceReset) {
if (opts.forceReset) {
logger.debug("accessSecretStorage: resetting 4S");
createNew = true;
} else if (!(await cli.secretStorage.hasKey())) {
Expand All @@ -236,9 +248,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
import("./async-components/views/dialogs/security/CreateSecretStorageDialog") as unknown as Promise<
typeof CreateSecretStorageDialog
>,
{
forceReset,
},
opts,
undefined,
/* priority = */ false,
/* static = */ true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface IProps {
hasCancel?: boolean;
accountPassword?: string;
forceReset?: boolean;
resetCrossSigning?: boolean;
onFinished(ok?: boolean): void;
}

Expand Down Expand Up @@ -91,6 +92,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
public static defaultProps: Partial<IProps> = {
hasCancel: true,
forceReset: false,
resetCrossSigning: false,
};
private recoveryKey?: GeneratedSecretStorageKey;
private recoveryKeyNode = createRef<HTMLElement>();
Expand Down Expand Up @@ -270,7 +272,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
private bootstrapSecretStorage = async (): Promise<void> => {
const cli = MatrixClientPeg.safeGet();
const crypto = cli.getCrypto()!;
const { forceReset } = this.props;
const { forceReset, resetCrossSigning } = this.props;

let backupInfo;
// First, unless we know we want to do a reset, we see if there is an existing key backup
Expand All @@ -292,12 +294,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp

try {
if (forceReset) {
/* Resetting cross-signing requires secret storage to be reset
* (otherwise it will try to store the cross-signing keys in the
* old secret storage, and may prompt for the old key, which is
* probably not available), and resetting key backup requires
* cross-signing to be reset (so that the new backup can be
* signed by the new cross-signing key). So we reset secret
* storage first, then cross-signing, then key backup.
*/
logger.log("Forcing secret storage reset");
await crypto.bootstrapSecretStorage({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not add the resetCrossSigning as an option to bootstrapSecretStorage?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mainly just to avoid making such an invasive change.

createSecretStorageKey: async () => this.recoveryKey!,
setupNewKeyBackup: true,
setupNewSecretStorage: true,
});
if (resetCrossSigning) {
logger.log("Resetting cross signing");
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
setupNewCrossSigning: true,
});
}
logger.log("Resetting key backup");
await crypto.resetKeyBackup();
} else {
// For password authentication users after 2020-09, this cross-signing
// step will be a no-op since it is now setup during registration or login
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import { _t } from "../../../../languageHandler";
import { accessSecretStorage } from "../../../../SecurityManager";
import Modal from "../../../../Modal";
import InteractiveAuthDialog from "../InteractiveAuthDialog";
import DialogButtons from "../../elements/DialogButtons";
import BaseDialog from "../BaseDialog";
import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds";
Expand Down Expand Up @@ -226,28 +225,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp

try {
// Force reset secret storage (which resets the key backup)
await accessSecretStorage(async (): Promise<void> => {
// Now reset cross-signing so everything Just Works™ again.
const cli = MatrixClientPeg.safeGet();
await cli.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
setupNewCrossSigning: true,
});

// Now we can indicate that the user is done pressing buttons, finally.
// Upstream flows will detect the new secret storage, key backup, etc and use it.
this.props.onFinished({});
}, true);
await accessSecretStorage(
async (): Promise<void> => {
// Now we can indicate that the user is done pressing buttons, finally.
// Upstream flows will detect the new secret storage, key backup, etc and use it.
this.props.onFinished({});
},
{ forceReset: true, resetCrossSigning: true },
);
} catch (e) {
logger.error(e);
this.props.onFinished(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,

private onResetRecoveryClick = (): void => {
this.props.onFinished(false);
accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
};

/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/settings/SecureBackupPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
private resetSecretStorage = async (): Promise<void> => {
this.setState({ error: false });
try {
await accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
await accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
} catch (e) {
logger.error("Error resetting secret storage", e);
if (this.unmounted) return;
Expand Down
49 changes: 10 additions & 39 deletions src/stores/SetupEncryptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ import { Device, SecretStorage } from "matrix-js-sdk/src/matrix";

import { MatrixClientPeg } from "../MatrixClientPeg";
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
import Modal from "../Modal";
import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDialog";
import { _t } from "../languageHandler";
import { SdkContextClass } from "../contexts/SDKContext";
import { asyncSome } from "../utils/arrays";
import { initialiseDehydration } from "../utils/device/dehydration";
Expand Down Expand Up @@ -229,42 +226,16 @@ export class SetupEncryptionStore extends EventEmitter {
// secret storage key if they had one. Start by resetting
// secret storage and setting up a new recovery key, then
// create new cross-signing keys once that succeeds.
await accessSecretStorage(async (): Promise<void> => {
const cli = MatrixClientPeg.safeGet();
await cli.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const cachedPassword = SdkContextClass.instance.accountPasswordStore.getPassword();

if (cachedPassword) {
await makeRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: cli.getSafeUserId(),
},
user: cli.getSafeUserId(),
password: cachedPassword,
});
return;
}

const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
setupNewCrossSigning: true,
});

await initialiseDehydration(true);

this.phase = Phase.Finished;
}, true);
await accessSecretStorage(
async (): Promise<void> => {
this.phase = Phase.Finished;
},
{
forceReset: true,
resetCrossSigning: true,
accountPassword: SdkContextClass.instance.accountPasswordStore.getPassword(),
},
);
} catch (e) {
logger.error("Error resetting cross-signing", e);
this.phase = Phase.Intro;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,34 @@ describe("AccessSecretStorageDialog", () => {

expect(screen.getByPlaceholderText("Security Phrase")).toHaveFocus();
});

it("Can reset secret storage", async () => {
jest.spyOn(mockClient.secretStorage, "checkKey").mockResolvedValue(true);

const onFinished = jest.fn();
const checkPrivateKey = jest.fn().mockResolvedValue(true);
renderComponent({ onFinished, checkPrivateKey });

await userEvent.click(screen.getByText("Reset all"), { delay: null });

// It will prompt the user to confirm resetting
expect(screen.getByText("Reset everything")).toBeInTheDocument();
await userEvent.click(screen.getByText("Reset"), { delay: null });

// Then it will prompt the user to create a key/passphrase
await screen.findByText("Set up Secure Backup");
document.execCommand = jest.fn().mockReturnValue(true);
jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({
privateKey: new Uint8Array(),
encodedPrivateKey: securityKey,
});
screen.getByRole("button", { name: "Continue" }).click();

await screen.findByText(/Save your Security Key/);
screen.getByRole("button", { name: "Copy" }).click();
await screen.findByText("Copied!");
screen.getByRole("button", { name: "Continue" }).click();

await screen.findByText("Secure Backup successful");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,38 @@ describe("CreateSecretStorageDialog", () => {
await screen.findByText("Your keys are now being backed up from this device.");
});
});

it("resets keys in the right order when resetting secret storage and cross-signing", async () => {
const result = renderComponent({ forceReset: true, resetCrossSigning: true });

await result.findByText(/Set up Secure Backup/);
jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({
privateKey: new Uint8Array(),
encodedPrivateKey: "abcd efgh ijkl",
});
result.getByRole("button", { name: "Continue" }).click();

await result.findByText(/Save your Security Key/);
result.getByRole("button", { name: "Copy" }).click();

// Resetting should reset secret storage, cross signing, and key
// backup. We make sure that all three are reset, and done in the
// right order.
const resetFunctionCallLog: string[] = [];
jest.spyOn(mockClient.getCrypto()!, "bootstrapSecretStorage").mockImplementation(async () => {
resetFunctionCallLog.push("bootstrapSecretStorage");
});
jest.spyOn(mockClient.getCrypto()!, "bootstrapCrossSigning").mockImplementation(async () => {
resetFunctionCallLog.push("bootstrapCrossSigning");
});
jest.spyOn(mockClient.getCrypto()!, "resetKeyBackup").mockImplementation(async () => {
resetFunctionCallLog.push("resetKeyBackup");
});

result.getByRole("button", { name: "Continue" }).click();

await result.findByText("Your keys are now being backed up from this device.");

expect(resetFunctionCallLog).toEqual(["bootstrapSecretStorage", "bootstrapCrossSigning", "resetKeyBackup"]);
});
});
13 changes: 4 additions & 9 deletions test/unit-tests/stores/SetupEncryptionStore-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,10 @@ describe("SetupEncryptionStore", () => {

await setupEncryptionStore.resetConfirm();

expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), true);
expect(makeRequest).toHaveBeenCalledWith({
identifier: {
type: "m.id.user",
user: "@userId:matrix.org",
},
password: cachedPassword,
type: "m.login.password",
user: "@userId:matrix.org",
expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), {
accountPassword: cachedPassword,
forceReset: true,
resetCrossSigning: true,
});
});
});
Loading