Skip to content

Commit b7f8623

Browse files
authored
Encryption tab: hide Advanced section when the key storage is out of sync (#29129)
* fix(encryption tab): hide the advanced section when the secrets are not cached locally The secret verification is now made at the level of `EncryptionUserSettingsTab` instead at the `RecoveryPanel` level. In the `EncryptionUserSettingsTab`, we decide to only display `RecoveryPanelOutOfSync` in case of uncached secrets. `RecoveryPanelOutOfSync` is simplified version of `RecoveryPanel` handling only the `secrets_not_cached` case. * refactor(encryption tab): simplify the `RecoveryPanel` without having to handle the missing secrets * test(encryption tab): move test about cached secrets in `EncryptionUserSettingsTab-test.tsx` * test(encryption tab): move e2e test which are testing all the encryption tab in `encryption-tab.spec.ts * refactor(encryption tab): move `RecoveryPanelOutOfSync` in its own file - fix typos - call onFinish after accessSecretStorage - onFinish doesn't need to be asynchronous * doc(encryption tab): improve documentation when the secrets are not cached locally * test(encryption tab): improve test documentation and naming * doc(encryption tab): improve `RecoveryPanelOutOfSync` documentation
1 parent e75ba35 commit b7f8623

File tree

13 files changed

+307
-227
lines changed

13 files changed

+307
-227
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
9+
10+
import { test, expect } from ".";
11+
import {
12+
checkDeviceIsConnectedKeyBackup,
13+
checkDeviceIsCrossSigned,
14+
createBot,
15+
deleteCachedSecrets,
16+
verifySession,
17+
} from "../../crypto/utils";
18+
19+
test.describe("Encryption tab", () => {
20+
test.use({
21+
displayName: "Alice",
22+
});
23+
24+
let recoveryKey: GeneratedSecretStorageKey;
25+
let expectedBackupVersion: string;
26+
27+
test.beforeEach(async ({ page, homeserver, credentials }) => {
28+
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
29+
const res = await createBot(page, homeserver, credentials);
30+
recoveryKey = res.recoveryKey;
31+
expectedBackupVersion = res.expectedBackupVersion;
32+
});
33+
34+
test(
35+
"should show a 'Verify this device' button if the device is unverified",
36+
{ tag: "@screenshot" },
37+
async ({ page, app, util }) => {
38+
const dialog = await util.openEncryptionTab();
39+
const content = util.getEncryptionTabContent();
40+
41+
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
42+
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
43+
await expect(verifyButton).toBeVisible();
44+
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
45+
await verifyButton.click();
46+
47+
await util.verifyDevice(recoveryKey);
48+
49+
await expect(content).toMatchScreenshot("default-tab.png", {
50+
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
51+
});
52+
53+
// Check that our device is now cross-signed
54+
await checkDeviceIsCrossSigned(app);
55+
56+
// Check that the current device is connected to key backup
57+
// The backup decryption key should be in cache also, as we got it directly from the 4S
58+
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
59+
},
60+
);
61+
62+
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
63+
//
64+
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
65+
// We simulate this case by deleting the cached secrets in the indexedDB.
66+
test(
67+
"should prompt to enter the recovery key when the secrets are not cached locally",
68+
{ tag: "@screenshot" },
69+
async ({ page, app, util }) => {
70+
await verifySession(app, "new passphrase");
71+
// We need to delete the cached secrets
72+
await deleteCachedSecrets(page);
73+
74+
await util.openEncryptionTab();
75+
// We ask the user to enter the recovery key
76+
const dialog = util.getEncryptionTabContent();
77+
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
78+
await expect(enterKeyButton).toBeVisible();
79+
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
80+
await enterKeyButton.click();
81+
82+
// Fill the recovery key
83+
await util.enterRecoveryKey(recoveryKey);
84+
await expect(dialog).toMatchScreenshot("default-tab.png", {
85+
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
86+
});
87+
88+
// Check that our device is now cross-signed
89+
await checkDeviceIsCrossSigned(app);
90+
91+
// Check that the current device is connected to key backup
92+
// The backup decryption key should be in cache also, as we got it directly from the 4S
93+
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
94+
},
95+
);
96+
});

playwright/e2e/settings/encryption-user-tab/recovery.spec.ts

Lines changed: 3 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,17 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
9-
108
import { test, expect } from ".";
11-
import {
12-
checkDeviceIsConnectedKeyBackup,
13-
checkDeviceIsCrossSigned,
14-
createBot,
15-
deleteCachedSecrets,
16-
verifySession,
17-
} from "../../crypto/utils";
9+
import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils";
1810

1911
test.describe("Recovery section in Encryption tab", () => {
2012
test.use({
2113
displayName: "Alice",
2214
});
2315

24-
let recoveryKey: GeneratedSecretStorageKey;
25-
let expectedBackupVersion: string;
26-
2716
test.beforeEach(async ({ page, homeserver, credentials }) => {
28-
const res = await createBot(page, homeserver, credentials);
29-
recoveryKey = res.recoveryKey;
30-
expectedBackupVersion = res.expectedBackupVersion;
31-
});
32-
33-
test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
34-
const dialog = await util.openEncryptionTab();
35-
const content = util.getEncryptionTabContent();
36-
37-
// The user's device is in an unverified state, therefore the only option available to them here is to verify it
38-
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
39-
await expect(verifyButton).toBeVisible();
40-
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
41-
await verifyButton.click();
42-
43-
await util.verifyDevice(recoveryKey);
44-
45-
await expect(content).toMatchScreenshot("default-tab.png", {
46-
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
47-
});
48-
49-
// Check that our device is now cross-signed
50-
await checkDeviceIsCrossSigned(app);
51-
52-
// Check that the current device is connected to key backup
53-
// The backup decryption key should be in cache also, as we got it directly from the 4S
54-
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
17+
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
18+
await createBot(page, homeserver, credentials);
5519
});
5620

5721
test(
@@ -121,37 +85,4 @@ test.describe("Recovery section in Encryption tab", () => {
12185
// Check that the current device is connected to key backup and the backup version is the expected one
12286
await checkDeviceIsConnectedKeyBackup(app, "1", true);
12387
});
124-
125-
// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
126-
//
127-
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
128-
// We simulate this case by deleting the cached secrets in the indexedDB.
129-
test(
130-
"should enter the recovery key when the secrets are not cached",
131-
{ tag: "@screenshot" },
132-
async ({ page, app, util }) => {
133-
await verifySession(app, "new passphrase");
134-
// We need to delete the cached secrets
135-
await deleteCachedSecrets(page);
136-
137-
await util.openEncryptionTab();
138-
// We ask the user to enter the recovery key
139-
const dialog = util.getEncryptionTabContent();
140-
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
141-
await expect(enterKeyButton).toBeVisible();
142-
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png");
143-
await enterKeyButton.click();
144-
145-
// Fill the recovery key
146-
await util.enterRecoveryKey(recoveryKey);
147-
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");
148-
149-
// Check that our device is now cross-signed
150-
await checkDeviceIsCrossSigned(app);
151-
152-
// Check that the current device is connected to key backup
153-
// The backup decryption key should be in cache also, as we got it directly from the 4S
154-
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
155-
},
156-
);
15788
});

src/components/views/settings/encryption/RecoveryPanel.tsx

Lines changed: 15 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,23 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import React, { JSX, useCallback, useEffect, useState } from "react";
8+
import React, { JSX } from "react";
99
import { Button, InlineSpinner } from "@vector-im/compound-web";
1010
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
1111

1212
import { SettingsSection } from "../shared/SettingsSection";
1313
import { _t } from "../../../../languageHandler";
1414
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
1515
import { SettingsHeader } from "../SettingsHeader";
16-
import { accessSecretStorage } from "../../../../SecurityManager";
17-
import { SettingsSubheader } from "../SettingsSubheader";
16+
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
1817

1918
/**
2019
* The possible states of the recovery panel.
2120
* - `loading`: We are checking the recovery key and the secrets.
2221
* - `missing_recovery_key`: The user has no recovery key.
23-
* - `secrets_not_cached`: The user has a recovery key but the secrets are not cached.
24-
* This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
2522
* - `good`: The user has a recovery key and the secrets are cached.
2623
*/
27-
type State = "loading" | "missing_recovery_key" | "secrets_not_cached" | "good";
24+
type State = "loading" | "missing_recovery_key" | "good";
2825

2926
interface RecoveryPanelProps {
3027
/**
@@ -40,29 +37,18 @@ interface RecoveryPanelProps {
4037
* This component allows the user to set up or change their recovery key.
4138
*/
4239
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
43-
const [state, setState] = useState<State>("loading");
44-
const isMissingRecoveryKey = state === "missing_recovery_key";
45-
4640
const matrixClient = useMatrixClientContext();
47-
48-
const checkEncryption = useCallback(async () => {
49-
const crypto = matrixClient.getCrypto()!;
50-
51-
// Check if the user has a recovery key
52-
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
53-
if (!hasRecoveryKey) return setState("missing_recovery_key");
54-
55-
// Check if the secrets are cached
56-
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
57-
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
58-
if (!secretsOk) return setState("secrets_not_cached");
59-
60-
setState("good");
61-
}, [matrixClient]);
62-
63-
useEffect(() => {
64-
checkEncryption();
65-
}, [checkEncryption]);
41+
const state = useAsyncMemo<State>(
42+
async () => {
43+
// Check if the user has a recovery key
44+
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
45+
if (hasRecoveryKey) return "good";
46+
else return "missing_recovery_key";
47+
},
48+
[matrixClient],
49+
"loading",
50+
);
51+
const isMissingRecoveryKey = state === "missing_recovery_key";
6652

6753
let content: JSX.Element;
6854
switch (state) {
@@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
7662
</Button>
7763
);
7864
break;
79-
case "secrets_not_cached":
80-
content = (
81-
<Button
82-
size="sm"
83-
kind="primary"
84-
Icon={KeyIcon}
85-
onClick={async () => await accessSecretStorage(checkEncryption)}
86-
>
87-
{_t("settings|encryption|recovery|enter_recovery_key")}
88-
</Button>
89-
);
90-
break;
9165
case "good":
9266
content = (
9367
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
@@ -105,33 +79,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
10579
label={_t("settings|encryption|recovery|title")}
10680
/>
10781
}
108-
subHeading={<Subheader state={state} />}
82+
subHeading={_t("settings|encryption|recovery|description")}
10983
data-testid="recoveryPanel"
11084
>
11185
{content}
11286
</SettingsSection>
11387
);
11488
}
115-
116-
interface SubheaderProps {
117-
/**
118-
* The state of the recovery panel.
119-
*/
120-
state: State;
121-
}
122-
123-
/**
124-
* The subheader for the recovery panel.
125-
*/
126-
function Subheader({ state }: SubheaderProps): JSX.Element {
127-
// If the secrets are not cached, we display a warning message.
128-
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;
129-
130-
return (
131-
<SettingsSubheader
132-
label={_t("settings|encryption|recovery|description")}
133-
state="error"
134-
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
135-
/>
136-
);
137-
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { JSX } from "react";
9+
import { Button } from "@vector-im/compound-web";
10+
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
11+
12+
import { SettingsSection } from "../shared/SettingsSection";
13+
import { _t } from "../../../../languageHandler";
14+
import { SettingsSubheader } from "../SettingsSubheader";
15+
import { accessSecretStorage } from "../../../../SecurityManager";
16+
17+
interface RecoveryPanelOutOfSyncProps {
18+
/**
19+
* Callback for when the user has finished entering their recovery key.
20+
*/
21+
onFinish: () => void;
22+
}
23+
24+
/**
25+
* This component is shown as part of the {@link EncryptionUserSettingsTab}, instead of the
26+
* {@link RecoveryPanel}, when some of the user secrets are not cached in the local client.
27+
*
28+
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
29+
* the client.
30+
*/
31+
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
32+
return (
33+
<SettingsSection
34+
legacy={false}
35+
heading={_t("settings|encryption|recovery|title")}
36+
subHeading={
37+
<SettingsSubheader
38+
label={_t("settings|encryption|recovery|description")}
39+
state="error"
40+
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
41+
/>
42+
}
43+
data-testid="recoveryPanel"
44+
>
45+
<Button
46+
size="sm"
47+
kind="primary"
48+
Icon={KeyIcon}
49+
onClick={async () => {
50+
await accessSecretStorage();
51+
onFinish();
52+
}}
53+
>
54+
{_t("settings|encryption|recovery|enter_recovery_key")}
55+
</Button>
56+
</SettingsSection>
57+
);
58+
}

0 commit comments

Comments
 (0)