Element-R: fix repeated requests to enter 4S key during cross-signing reset (#12059)

* Remove redundant `forceReset` parameter

This was always true, so let's get rid of it.

Also some function renames.

* Factor out new `withSecretStorageKeyCache` helper

... so that we can use the cache without the whole of `accessSecretStorage`.

* Cache secret storage key during cross-signing reset

* Playwright test for resetting cross-signing

* CrossSigningPanel: Silence annoying react warnings

React complains if we don't include an explicit `tbody`.

* Simple unit test of reset button
pull/28217/head
Richard van der Hoff 2023-12-15 14:59:36 +00:00 committed by GitHub
parent a7c039d314
commit de5931d5a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 229 additions and 143 deletions

View File

@ -212,6 +212,43 @@ test.describe("Cryptography", function () {
}); });
} }
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
const secretStorageKey = await enableKeyBackup(app);
// Fetch the current cross-signing keys
async function fetchMasterKey() {
return await test.step("Fetch master key from server", async () => {
const k = await app.client.evaluate(async (cli) => {
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return Object.values(keys.master_keys[userId].keys)[0];
});
console.log(`fetchMasterKey: ${k}`);
return k;
});
}
const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it
await app.settings.openUserSettings("Security & Privacy");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
// Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
await expect(async () => {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
}).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
});
test("creating a DM should work, being e2e-encrypted / user verification", async ({ test("creating a DM should work, being e2e-encrypted / user verification", async ({
page, page,
app, app,

View File

@ -299,6 +299,28 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
return key; return key;
} }
/**
* Carry out an operation that may require multiple accesses to secret storage, caching the key.
*
* Use this helper to wrap an operation that may require multiple accesses to secret storage; the user will be prompted
* to enter the 4S key or passphrase on the first access, and the key will be cached for the rest of the operation.
*
* @param func - The operation to be wrapped.
*/
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
secretStorageBeingAccessed = true;
try {
return await func();
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
}
}
/** /**
* 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
@ -326,7 +348,15 @@ export async function accessSecretStorage(
forceReset = false, forceReset = false,
setupNewKeyBackup = true, setupNewKeyBackup = true,
): Promise<void> { ): Promise<void> {
secretStorageBeingAccessed = true; await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset, setupNewKeyBackup));
}
/** Helper for {@link #accessSecretStorage} */
async function doAccessSecretStorage(
func: () => Promise<void>,
forceReset: boolean,
setupNewKeyBackup: boolean,
): Promise<void> {
try { try {
const cli = MatrixClientPeg.safeGet(); const cli = MatrixClientPeg.safeGet();
if (!(await cli.hasSecretStorageKey()) || forceReset) { if (!(await cli.hasSecretStorageKey()) || forceReset) {
@ -403,13 +433,6 @@ export async function accessSecretStorage(
logger.error(e); logger.error(e);
// Re-throw so that higher level logic can abort as needed // Re-throw so that higher level logic can abort as needed
throw e; throw e;
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
secretStorageKeyInfo = {};
}
} }
} }

View File

@ -26,7 +26,7 @@ import Spinner from "../elements/Spinner";
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog"; import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog"; import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog"; import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
import { accessSecretStorage } from "../../../SecurityManager"; import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { SettingsSubsectionText } from "./shared/SettingsSubsection"; import { SettingsSubsectionText } from "./shared/SettingsSubsection";
@ -118,31 +118,27 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
} }
/** /**
* Bootstrapping cross-signing take one of these paths: * Reset the user's cross-signing keys.
* 1. Create cross-signing keys locally and store in secret storage (if it
* already exists on the account).
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
* @param {bool} [forceReset] Bootstrap again even if keys already present
*/ */
private bootstrapCrossSigning = async ({ forceReset = false }): Promise<void> => { private async resetCrossSigning(): Promise<void> {
this.setState({ error: false }); this.setState({ error: false });
try { try {
const cli = MatrixClientPeg.safeGet(); const cli = MatrixClientPeg.safeGet();
await cli.bootstrapCrossSigning({ await withSecretStorageKeyCache(async () => {
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => { await cli.getCrypto()!.bootstrapCrossSigning({
const { finished } = Modal.createDialog(InteractiveAuthDialog, { authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
title: _t("encryption|bootstrap_title"), const { finished } = Modal.createDialog(InteractiveAuthDialog, {
matrixClient: cli, title: _t("encryption|bootstrap_title"),
makeRequest, matrixClient: cli,
}); makeRequest,
const [confirmed] = await finished; });
if (!confirmed) { const [confirmed] = await finished;
throw new Error("Cross-signing key upload auth canceled"); if (!confirmed) {
} throw new Error("Cross-signing key upload auth canceled");
}, }
setupNewCrossSigning: forceReset, },
setupNewCrossSigning: true,
});
}); });
} catch (e) { } catch (e) {
this.setState({ error: true }); this.setState({ error: true });
@ -150,13 +146,18 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
} }
if (this.unmounted) return; if (this.unmounted) return;
this.getUpdatedStatus(); this.getUpdatedStatus();
}; }
private resetCrossSigning = (): void => { /**
* Callback for when the user clicks the "reset cross signing" button.
*
* Shows a confirmation dialog, and then does the reset if confirmed.
*/
private onResetCrossSigningClick = (): void => {
Modal.createDialog(ConfirmDestroyCrossSigningDialog, { Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
onFinished: (act) => { onFinished: async (act) => {
if (!act) return; if (!act) return;
this.bootstrapCrossSigning({ forceReset: true }); this.resetCrossSigning();
}, },
}); });
}; };
@ -243,7 +244,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
if (keysExistAnywhere) { if (keysExistAnywhere) {
actions.push( actions.push(
<AccessibleButton key="reset" kind="danger" onClick={this.resetCrossSigning}> <AccessibleButton key="reset" kind="danger" onClick={this.onResetCrossSigningClick}>
{_t("action|reset")} {_t("action|reset")}
</AccessibleButton>, </AccessibleButton>,
); );
@ -260,54 +261,56 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
<details> <details>
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary> <summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
<table className="mx_CrossSigningPanel_statusList"> <table className="mx_CrossSigningPanel_statusList">
<tr> <tbody>
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
{crossSigningPublicKeysOnDevice <td>
? _t("settings|security|cross_signing_in_memory") {crossSigningPublicKeysOnDevice
: _t("settings|security|cross_signing_not_found")} ? _t("settings|security|cross_signing_in_memory")
</td> : _t("settings|security|cross_signing_not_found")}
</tr> </td>
<tr> </tr>
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
{crossSigningPrivateKeysInStorage <td>
? _t("settings|security|cross_signing_in_4s") {crossSigningPrivateKeysInStorage
: _t("settings|security|cross_signing_not_in_4s")} ? _t("settings|security|cross_signing_in_4s")
</td> : _t("settings|security|cross_signing_not_in_4s")}
</tr> </td>
<tr> </tr>
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
{masterPrivateKeyCached <td>
? _t("settings|security|cross_signing_cached") {masterPrivateKeyCached
: _t("settings|security|cross_signing_not_cached")} ? _t("settings|security|cross_signing_cached")
</td> : _t("settings|security|cross_signing_not_cached")}
</tr> </td>
<tr> </tr>
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
{selfSigningPrivateKeyCached <td>
? _t("settings|security|cross_signing_cached") {selfSigningPrivateKeyCached
: _t("settings|security|cross_signing_not_cached")} ? _t("settings|security|cross_signing_cached")
</td> : _t("settings|security|cross_signing_not_cached")}
</tr> </td>
<tr> </tr>
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
{userSigningPrivateKeyCached <td>
? _t("settings|security|cross_signing_cached") {userSigningPrivateKeyCached
: _t("settings|security|cross_signing_not_cached")} ? _t("settings|security|cross_signing_cached")
</td> : _t("settings|security|cross_signing_not_cached")}
</tr> </td>
<tr> </tr>
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th> <tr>
<td> <th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
{homeserverSupportsCrossSigning <td>
? _t("settings|security|cross_signing_homeserver_support_exists") {homeserverSupportsCrossSigning
: _t("settings|security|cross_signing_not_found")} ? _t("settings|security|cross_signing_homeserver_support_exists")
</td> : _t("settings|security|cross_signing_not_found")}
</tr> </td>
</tr>
</tbody>
</table> </table>
</details> </details>
{errorSection} {errorSection}

View File

@ -26,6 +26,8 @@ import {
mockClientMethodsCrypto, mockClientMethodsCrypto,
mockClientMethodsUser, mockClientMethodsUser,
} from "../../../test-utils"; } from "../../../test-utils";
import Modal from "../../../../src/Modal";
import ConfirmDestroyCrossSigningDialog from "../../../../src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog";
describe("<CrossSigningPanel />", () => { describe("<CrossSigningPanel />", () => {
const userId = "@alice:server.org"; const userId = "@alice:server.org";
@ -43,6 +45,10 @@ describe("<CrossSigningPanel />", () => {
mockClient.isCrossSigningReady.mockResolvedValue(false); mockClient.isCrossSigningReady.mockResolvedValue(false);
}); });
afterEach(() => {
jest.restoreAllMocks();
});
it("should render a spinner while loading", () => { it("should render a spinner while loading", () => {
getComponent(); getComponent();
@ -85,6 +91,21 @@ describe("<CrossSigningPanel />", () => {
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use."); expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot(); expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
}); });
it("should allow reset of cross-signing", async () => {
mockClient.getCrypto()!.bootstrapCrossSigning = jest.fn().mockResolvedValue(undefined);
getComponent();
await flushPromises();
const modalSpy = jest.spyOn(Modal, "createDialog");
screen.getByRole("button", { name: "Reset" }).click();
expect(modalSpy).toHaveBeenCalledWith(ConfirmDestroyCrossSigningDialog, expect.any(Object));
modalSpy.mock.lastCall![1]!.onFinished(true);
expect(mockClient.getCrypto()!.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({ setupNewCrossSigning: true }),
);
});
}); });
describe("when cross signing is not ready", () => { describe("when cross signing is not ready", () => {

View File

@ -175,66 +175,68 @@ exports[`<SecurityUserSettingsTab /> renders security section 1`] = `
<table <table
class="mx_CrossSigningPanel_statusList" class="mx_CrossSigningPanel_statusList"
> >
<tr> <tbody>
<th <tr>
scope="row" <th
> scope="row"
Cross-signing public keys: >
</th> Cross-signing public keys:
<td> </th>
not found <td>
</td> not found
</tr> </td>
<tr> </tr>
<th <tr>
scope="row" <th
> scope="row"
Cross-signing private keys: >
</th> Cross-signing private keys:
<td> </th>
not found in storage <td>
</td> not found in storage
</tr> </td>
<tr> </tr>
<th <tr>
scope="row" <th
> scope="row"
Master private key: >
</th> Master private key:
<td> </th>
not found locally <td>
</td> not found locally
</tr> </td>
<tr> </tr>
<th <tr>
scope="row" <th
> scope="row"
Self signing private key: >
</th> Self signing private key:
<td> </th>
not found locally <td>
</td> not found locally
</tr> </td>
<tr> </tr>
<th <tr>
scope="row" <th
> scope="row"
User signing private key: >
</th> User signing private key:
<td> </th>
not found locally <td>
</td> not found locally
</tr> </td>
<tr> </tr>
<th <tr>
scope="row" <th
> scope="row"
Homeserver feature support: >
</th> Homeserver feature support:
<td> </th>
not found <td>
</td> not found
</tr> </td>
</tr>
</tbody>
</table> </table>
</details> </details>
</div> </div>