Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit de5931d

Browse files
authored
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
1 parent a7c039d commit de5931d

File tree

5 files changed

+229
-143
lines changed

5 files changed

+229
-143
lines changed

playwright/e2e/crypto/crypto.spec.ts

+37
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,43 @@ test.describe("Cryptography", function () {
212212
});
213213
}
214214

215+
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
216+
const secretStorageKey = await enableKeyBackup(app);
217+
218+
// Fetch the current cross-signing keys
219+
async function fetchMasterKey() {
220+
return await test.step("Fetch master key from server", async () => {
221+
const k = await app.client.evaluate(async (cli) => {
222+
const userId = cli.getUserId();
223+
const keys = await cli.downloadKeysForUsers([userId]);
224+
return Object.values(keys.master_keys[userId].keys)[0];
225+
});
226+
console.log(`fetchMasterKey: ${k}`);
227+
return k;
228+
});
229+
}
230+
const masterKey1 = await fetchMasterKey();
231+
232+
// Find the "reset cross signing" button, and click it
233+
await app.settings.openUserSettings("Security & Privacy");
234+
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
235+
236+
// Confirm
237+
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
238+
239+
// Enter the 4S key
240+
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
241+
await page.getByRole("button", { name: "Continue" }).click();
242+
243+
await expect(async () => {
244+
const masterKey2 = await fetchMasterKey();
245+
expect(masterKey1).not.toEqual(masterKey2);
246+
}).toPass();
247+
248+
// The dialog should have gone away
249+
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
250+
});
251+
215252
test("creating a DM should work, being e2e-encrypted / user verification", async ({
216253
page,
217254
app,

src/SecurityManager.ts

+31-8
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,28 @@ export async function promptForBackupPassphrase(): Promise<Uint8Array> {
299299
return key;
300300
}
301301

302+
/**
303+
* Carry out an operation that may require multiple accesses to secret storage, caching the key.
304+
*
305+
* Use this helper to wrap an operation that may require multiple accesses to secret storage; the user will be prompted
306+
* to enter the 4S key or passphrase on the first access, and the key will be cached for the rest of the operation.
307+
*
308+
* @param func - The operation to be wrapped.
309+
*/
310+
export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Promise<T> {
311+
secretStorageBeingAccessed = true;
312+
try {
313+
return await func();
314+
} finally {
315+
// Clear secret storage key cache now that work is complete
316+
secretStorageBeingAccessed = false;
317+
if (!isCachingAllowed()) {
318+
secretStorageKeys = {};
319+
secretStorageKeyInfo = {};
320+
}
321+
}
322+
}
323+
302324
/**
303325
* This helper should be used whenever you need to access secret storage. It
304326
* ensures that secret storage (and also cross-signing since they each depend on
@@ -326,7 +348,15 @@ export async function accessSecretStorage(
326348
forceReset = false,
327349
setupNewKeyBackup = true,
328350
): Promise<void> {
329-
secretStorageBeingAccessed = true;
351+
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset, setupNewKeyBackup));
352+
}
353+
354+
/** Helper for {@link #accessSecretStorage} */
355+
async function doAccessSecretStorage(
356+
func: () => Promise<void>,
357+
forceReset: boolean,
358+
setupNewKeyBackup: boolean,
359+
): Promise<void> {
330360
try {
331361
const cli = MatrixClientPeg.safeGet();
332362
if (!(await cli.hasSecretStorageKey()) || forceReset) {
@@ -403,13 +433,6 @@ export async function accessSecretStorage(
403433
logger.error(e);
404434
// Re-throw so that higher level logic can abort as needed
405435
throw e;
406-
} finally {
407-
// Clear secret storage key cache now that work is complete
408-
secretStorageBeingAccessed = false;
409-
if (!isCachingAllowed()) {
410-
secretStorageKeys = {};
411-
secretStorageKeyInfo = {};
412-
}
413436
}
414437
}
415438

src/components/views/settings/CrossSigningPanel.tsx

+78-75
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import Spinner from "../elements/Spinner";
2626
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
2727
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
2828
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
29-
import { accessSecretStorage } from "../../../SecurityManager";
29+
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
3030
import AccessibleButton from "../elements/AccessibleButton";
3131
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
3232

@@ -118,45 +118,46 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
118118
}
119119

120120
/**
121-
* Bootstrapping cross-signing take one of these paths:
122-
* 1. Create cross-signing keys locally and store in secret storage (if it
123-
* already exists on the account).
124-
* 2. Access existing secret storage by requesting passphrase and accessing
125-
* cross-signing keys as needed.
126-
* 3. All keys are loaded and there's nothing to do.
127-
* @param {bool} [forceReset] Bootstrap again even if keys already present
121+
* Reset the user's cross-signing keys.
128122
*/
129-
private bootstrapCrossSigning = async ({ forceReset = false }): Promise<void> => {
123+
private async resetCrossSigning(): Promise<void> {
130124
this.setState({ error: false });
131125
try {
132126
const cli = MatrixClientPeg.safeGet();
133-
await cli.bootstrapCrossSigning({
134-
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
135-
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
136-
title: _t("encryption|bootstrap_title"),
137-
matrixClient: cli,
138-
makeRequest,
139-
});
140-
const [confirmed] = await finished;
141-
if (!confirmed) {
142-
throw new Error("Cross-signing key upload auth canceled");
143-
}
144-
},
145-
setupNewCrossSigning: forceReset,
127+
await withSecretStorageKeyCache(async () => {
128+
await cli.getCrypto()!.bootstrapCrossSigning({
129+
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
130+
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
131+
title: _t("encryption|bootstrap_title"),
132+
matrixClient: cli,
133+
makeRequest,
134+
});
135+
const [confirmed] = await finished;
136+
if (!confirmed) {
137+
throw new Error("Cross-signing key upload auth canceled");
138+
}
139+
},
140+
setupNewCrossSigning: true,
141+
});
146142
});
147143
} catch (e) {
148144
this.setState({ error: true });
149145
logger.error("Error bootstrapping cross-signing", e);
150146
}
151147
if (this.unmounted) return;
152148
this.getUpdatedStatus();
153-
};
149+
}
154150

155-
private resetCrossSigning = (): void => {
151+
/**
152+
* Callback for when the user clicks the "reset cross signing" button.
153+
*
154+
* Shows a confirmation dialog, and then does the reset if confirmed.
155+
*/
156+
private onResetCrossSigningClick = (): void => {
156157
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
157-
onFinished: (act) => {
158+
onFinished: async (act) => {
158159
if (!act) return;
159-
this.bootstrapCrossSigning({ forceReset: true });
160+
this.resetCrossSigning();
160161
},
161162
});
162163
};
@@ -243,7 +244,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
243244

244245
if (keysExistAnywhere) {
245246
actions.push(
246-
<AccessibleButton key="reset" kind="danger" onClick={this.resetCrossSigning}>
247+
<AccessibleButton key="reset" kind="danger" onClick={this.onResetCrossSigningClick}>
247248
{_t("action|reset")}
248249
</AccessibleButton>,
249250
);
@@ -260,54 +261,56 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
260261
<details>
261262
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
262263
<table className="mx_CrossSigningPanel_statusList">
263-
<tr>
264-
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
265-
<td>
266-
{crossSigningPublicKeysOnDevice
267-
? _t("settings|security|cross_signing_in_memory")
268-
: _t("settings|security|cross_signing_not_found")}
269-
</td>
270-
</tr>
271-
<tr>
272-
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
273-
<td>
274-
{crossSigningPrivateKeysInStorage
275-
? _t("settings|security|cross_signing_in_4s")
276-
: _t("settings|security|cross_signing_not_in_4s")}
277-
</td>
278-
</tr>
279-
<tr>
280-
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
281-
<td>
282-
{masterPrivateKeyCached
283-
? _t("settings|security|cross_signing_cached")
284-
: _t("settings|security|cross_signing_not_cached")}
285-
</td>
286-
</tr>
287-
<tr>
288-
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
289-
<td>
290-
{selfSigningPrivateKeyCached
291-
? _t("settings|security|cross_signing_cached")
292-
: _t("settings|security|cross_signing_not_cached")}
293-
</td>
294-
</tr>
295-
<tr>
296-
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
297-
<td>
298-
{userSigningPrivateKeyCached
299-
? _t("settings|security|cross_signing_cached")
300-
: _t("settings|security|cross_signing_not_cached")}
301-
</td>
302-
</tr>
303-
<tr>
304-
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
305-
<td>
306-
{homeserverSupportsCrossSigning
307-
? _t("settings|security|cross_signing_homeserver_support_exists")
308-
: _t("settings|security|cross_signing_not_found")}
309-
</td>
310-
</tr>
264+
<tbody>
265+
<tr>
266+
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
267+
<td>
268+
{crossSigningPublicKeysOnDevice
269+
? _t("settings|security|cross_signing_in_memory")
270+
: _t("settings|security|cross_signing_not_found")}
271+
</td>
272+
</tr>
273+
<tr>
274+
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
275+
<td>
276+
{crossSigningPrivateKeysInStorage
277+
? _t("settings|security|cross_signing_in_4s")
278+
: _t("settings|security|cross_signing_not_in_4s")}
279+
</td>
280+
</tr>
281+
<tr>
282+
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
283+
<td>
284+
{masterPrivateKeyCached
285+
? _t("settings|security|cross_signing_cached")
286+
: _t("settings|security|cross_signing_not_cached")}
287+
</td>
288+
</tr>
289+
<tr>
290+
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
291+
<td>
292+
{selfSigningPrivateKeyCached
293+
? _t("settings|security|cross_signing_cached")
294+
: _t("settings|security|cross_signing_not_cached")}
295+
</td>
296+
</tr>
297+
<tr>
298+
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
299+
<td>
300+
{userSigningPrivateKeyCached
301+
? _t("settings|security|cross_signing_cached")
302+
: _t("settings|security|cross_signing_not_cached")}
303+
</td>
304+
</tr>
305+
<tr>
306+
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
307+
<td>
308+
{homeserverSupportsCrossSigning
309+
? _t("settings|security|cross_signing_homeserver_support_exists")
310+
: _t("settings|security|cross_signing_not_found")}
311+
</td>
312+
</tr>
313+
</tbody>
311314
</table>
312315
</details>
313316
{errorSection}

test/components/views/settings/CrossSigningPanel-test.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
mockClientMethodsCrypto,
2727
mockClientMethodsUser,
2828
} from "../../../test-utils";
29+
import Modal from "../../../../src/Modal";
30+
import ConfirmDestroyCrossSigningDialog from "../../../../src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog";
2931

3032
describe("<CrossSigningPanel />", () => {
3133
const userId = "@alice:server.org";
@@ -43,6 +45,10 @@ describe("<CrossSigningPanel />", () => {
4345
mockClient.isCrossSigningReady.mockResolvedValue(false);
4446
});
4547

48+
afterEach(() => {
49+
jest.restoreAllMocks();
50+
});
51+
4652
it("should render a spinner while loading", () => {
4753
getComponent();
4854

@@ -85,6 +91,21 @@ describe("<CrossSigningPanel />", () => {
8591
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
8692
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
8793
});
94+
95+
it("should allow reset of cross-signing", async () => {
96+
mockClient.getCrypto()!.bootstrapCrossSigning = jest.fn().mockResolvedValue(undefined);
97+
getComponent();
98+
await flushPromises();
99+
100+
const modalSpy = jest.spyOn(Modal, "createDialog");
101+
102+
screen.getByRole("button", { name: "Reset" }).click();
103+
expect(modalSpy).toHaveBeenCalledWith(ConfirmDestroyCrossSigningDialog, expect.any(Object));
104+
modalSpy.mock.lastCall![1]!.onFinished(true);
105+
expect(mockClient.getCrypto()!.bootstrapCrossSigning).toHaveBeenCalledWith(
106+
expect.objectContaining({ setupNewCrossSigning: true }),
107+
);
108+
});
88109
});
89110

90111
describe("when cross signing is not ready", () => {

0 commit comments

Comments
 (0)