Reset cross-signing before backup when resetting both (#28402)
* reset cross-signing before backup when resetting both * add test for AccessSecretStorageDialog * fix unit test
This commit is contained in:
parent
ed9795137b
commit
0ae74a9e1f
10 changed files with 127 additions and 84 deletions
|
@ -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
|
* 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
|
* ensures that secret storage (and also cross-signing since they each depend on
|
||||||
|
@ -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
|
* @param {Function} [func] An operation to perform once secret storage has been
|
||||||
* bootstrapped. Optional.
|
* 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> {
|
export async function accessSecretStorage(
|
||||||
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset));
|
func = async (): Promise<void> => {},
|
||||||
|
opts: AccessSecretStorageOpts = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper for {@link #accessSecretStorage} */
|
/** 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 {
|
try {
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
const crypto = cli.getCrypto();
|
const crypto = cli.getCrypto();
|
||||||
|
@ -221,7 +233,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
let createNew = false;
|
let createNew = false;
|
||||||
if (forceReset) {
|
if (opts.forceReset) {
|
||||||
logger.debug("accessSecretStorage: resetting 4S");
|
logger.debug("accessSecretStorage: resetting 4S");
|
||||||
createNew = true;
|
createNew = true;
|
||||||
} else if (!(await cli.secretStorage.hasKey())) {
|
} else if (!(await cli.secretStorage.hasKey())) {
|
||||||
|
@ -234,9 +246,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
|
||||||
// passphrase creation.
|
// passphrase creation.
|
||||||
const { finished } = Modal.createDialog(
|
const { finished } = Modal.createDialog(
|
||||||
lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")),
|
lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")),
|
||||||
{
|
opts,
|
||||||
forceReset,
|
|
||||||
},
|
|
||||||
undefined,
|
undefined,
|
||||||
/* priority = */ false,
|
/* priority = */ false,
|
||||||
/* static = */ true,
|
/* static = */ true,
|
||||||
|
|
|
@ -58,6 +58,7 @@ interface IProps {
|
||||||
hasCancel?: boolean;
|
hasCancel?: boolean;
|
||||||
accountPassword?: string;
|
accountPassword?: string;
|
||||||
forceReset?: boolean;
|
forceReset?: boolean;
|
||||||
|
resetCrossSigning?: boolean;
|
||||||
onFinished(ok?: boolean): void;
|
onFinished(ok?: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +92,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||||
public static defaultProps: Partial<IProps> = {
|
public static defaultProps: Partial<IProps> = {
|
||||||
hasCancel: true,
|
hasCancel: true,
|
||||||
forceReset: false,
|
forceReset: false,
|
||||||
|
resetCrossSigning: false,
|
||||||
};
|
};
|
||||||
private recoveryKey?: GeneratedSecretStorageKey;
|
private recoveryKey?: GeneratedSecretStorageKey;
|
||||||
private recoveryKeyNode = createRef<HTMLElement>();
|
private recoveryKeyNode = createRef<HTMLElement>();
|
||||||
|
@ -270,7 +272,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||||
private bootstrapSecretStorage = async (): Promise<void> => {
|
private bootstrapSecretStorage = async (): Promise<void> => {
|
||||||
const cli = MatrixClientPeg.safeGet();
|
const cli = MatrixClientPeg.safeGet();
|
||||||
const crypto = cli.getCrypto()!;
|
const crypto = cli.getCrypto()!;
|
||||||
const { forceReset } = this.props;
|
const { forceReset, resetCrossSigning } = this.props;
|
||||||
|
|
||||||
let backupInfo;
|
let backupInfo;
|
||||||
// First, unless we know we want to do a reset, we see if there is an existing key backup
|
// First, unless we know we want to do a reset, we see if there is an existing key backup
|
||||||
|
@ -292,12 +294,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (forceReset) {
|
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");
|
logger.log("Forcing secret storage reset");
|
||||||
await crypto.bootstrapSecretStorage({
|
await crypto.bootstrapSecretStorage({
|
||||||
createSecretStorageKey: async () => this.recoveryKey!,
|
createSecretStorageKey: async () => this.recoveryKey!,
|
||||||
setupNewKeyBackup: true,
|
|
||||||
setupNewSecretStorage: 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 {
|
} else {
|
||||||
// For password authentication users after 2020-09, this cross-signing
|
// 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
|
// step will be a no-op since it is now setup during registration or login
|
||||||
|
|
|
@ -19,7 +19,6 @@ import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
||||||
import { _t } from "../../../../languageHandler";
|
import { _t } from "../../../../languageHandler";
|
||||||
import { accessSecretStorage } from "../../../../SecurityManager";
|
import { accessSecretStorage } from "../../../../SecurityManager";
|
||||||
import Modal from "../../../../Modal";
|
import Modal from "../../../../Modal";
|
||||||
import InteractiveAuthDialog from "../InteractiveAuthDialog";
|
|
||||||
import DialogButtons from "../../elements/DialogButtons";
|
import DialogButtons from "../../elements/DialogButtons";
|
||||||
import BaseDialog from "../BaseDialog";
|
import BaseDialog from "../BaseDialog";
|
||||||
import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds";
|
import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds";
|
||||||
|
@ -226,28 +225,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Force reset secret storage (which resets the key backup)
|
// Force reset secret storage (which resets the key backup)
|
||||||
await accessSecretStorage(async (): Promise<void> => {
|
await accessSecretStorage(
|
||||||
// Now reset cross-signing so everything Just Works™ again.
|
async (): Promise<void> => {
|
||||||
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.
|
// 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.
|
// Upstream flows will detect the new secret storage, key backup, etc and use it.
|
||||||
this.props.onFinished({});
|
this.props.onFinished({});
|
||||||
}, true);
|
},
|
||||||
|
{ forceReset: true, resetCrossSigning: true },
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
this.props.onFinished(false);
|
this.props.onFinished(false);
|
||||||
|
|
|
@ -109,7 +109,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||||
|
|
||||||
private onResetRecoveryClick = (): void => {
|
private onResetRecoveryClick = (): void => {
|
||||||
this.props.onFinished(false);
|
this.props.onFinished(false);
|
||||||
accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
|
accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -209,7 +209,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||||
private resetSecretStorage = async (): Promise<void> => {
|
private resetSecretStorage = async (): Promise<void> => {
|
||||||
this.setState({ error: false });
|
this.setState({ error: false });
|
||||||
try {
|
try {
|
||||||
await accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
|
await accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error resetting secret storage", e);
|
logger.error("Error resetting secret storage", e);
|
||||||
if (this.unmounted) return;
|
if (this.unmounted) return;
|
||||||
|
|
|
@ -19,9 +19,6 @@ import { Device, SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||||
import { AccessCancelledError, accessSecretStorage } from "../SecurityManager";
|
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 { SdkContextClass } from "../contexts/SDKContext";
|
||||||
import { asyncSome } from "../utils/arrays";
|
import { asyncSome } from "../utils/arrays";
|
||||||
import { initialiseDehydration } from "../utils/device/dehydration";
|
import { initialiseDehydration } from "../utils/device/dehydration";
|
||||||
|
@ -230,42 +227,16 @@ export class SetupEncryptionStore extends EventEmitter {
|
||||||
// secret storage key if they had one. Start by resetting
|
// secret storage key if they had one. Start by resetting
|
||||||
// secret storage and setting up a new recovery key, then
|
// secret storage and setting up a new recovery key, then
|
||||||
// create new cross-signing keys once that succeeds.
|
// create new cross-signing keys once that succeeds.
|
||||||
await accessSecretStorage(async (): Promise<void> => {
|
await accessSecretStorage(
|
||||||
const cli = MatrixClientPeg.safeGet();
|
async (): Promise<void> => {
|
||||||
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;
|
this.phase = Phase.Finished;
|
||||||
}, true);
|
},
|
||||||
|
{
|
||||||
|
forceReset: true,
|
||||||
|
resetCrossSigning: true,
|
||||||
|
accountPassword: SdkContextClass.instance.accountPasswordStore.getPassword(),
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error resetting cross-signing", e);
|
logger.error("Error resetting cross-signing", e);
|
||||||
this.phase = Phase.Intro;
|
this.phase = Phase.Intro;
|
||||||
|
|
|
@ -68,7 +68,7 @@ describe("SecurityManager", () => {
|
||||||
stubClient();
|
stubClient();
|
||||||
|
|
||||||
const func = jest.fn();
|
const func = jest.fn();
|
||||||
accessSecretStorage(func, true);
|
accessSecretStorage(func, { forceReset: true });
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true }));
|
await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true }));
|
||||||
|
|
|
@ -122,4 +122,34 @@ describe("AccessSecretStorageDialog", () => {
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText("Security Phrase")).toHaveFocus();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -97,4 +97,38 @@ describe("CreateSecretStorageDialog", () => {
|
||||||
await screen.findByText("Your keys are now being backed up from this device.");
|
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -170,15 +170,10 @@ describe("SetupEncryptionStore", () => {
|
||||||
|
|
||||||
await setupEncryptionStore.resetConfirm();
|
await setupEncryptionStore.resetConfirm();
|
||||||
|
|
||||||
expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), true);
|
expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
expect(makeRequest).toHaveBeenCalledWith({
|
accountPassword: cachedPassword,
|
||||||
identifier: {
|
forceReset: true,
|
||||||
type: "m.id.user",
|
resetCrossSigning: true,
|
||||||
user: "@userId:matrix.org",
|
|
||||||
},
|
|
||||||
password: cachedPassword,
|
|
||||||
type: "m.login.password",
|
|
||||||
user: "@userId:matrix.org",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue