Skip to content

Commit

Permalink
Encryption tab: hide Advanced section when the key storage is out o…
Browse files Browse the repository at this point in the history
…f 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
  • Loading branch information
florianduros authored Feb 3, 2025
1 parent e75ba35 commit b7f8623
Show file tree
Hide file tree
Showing 13 changed files with 307 additions and 227 deletions.
96 changes: 96 additions & 0 deletions playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";

import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";

test.describe("Encryption tab", () => {
test.use({
displayName: "Alice",
});

let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;

test.beforeEach(async ({ page, homeserver, credentials }) => {
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});

test(
"should show a 'Verify this device' button if the device is unverified",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
const content = util.getEncryptionTabContent();

// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();

await util.verifyDevice(recoveryKey);

await expect(content).toMatchScreenshot("default-tab.png", {
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
});

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);

// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should prompt to enter the recovery key when the secrets are not cached locally",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);

await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(dialog).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();

// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(dialog).toMatchScreenshot("default-tab.png", {
mask: [dialog.getByTestId("deviceId"), dialog.getByTestId("sessionKey")],
});

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);
});
75 changes: 3 additions & 72 deletions playwright/e2e/settings/encryption-user-tab/recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,17 @@
* Please see LICENSE files in the repository root for full details.
*/

import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";

import { test, expect } from ".";
import {
checkDeviceIsConnectedKeyBackup,
checkDeviceIsCrossSigned,
createBot,
deleteCachedSecrets,
verifySession,
} from "../../crypto/utils";
import { checkDeviceIsConnectedKeyBackup, createBot, verifySession } from "../../crypto/utils";

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

let recoveryKey: GeneratedSecretStorageKey;
let expectedBackupVersion: string;

test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;
expectedBackupVersion = res.expectedBackupVersion;
});

test("should verify the device", { tag: "@screenshot" }, async ({ page, app, util }) => {
const dialog = await util.openEncryptionTab();
const content = util.getEncryptionTabContent();

// The user's device is in an unverified state, therefore the only option available to them here is to verify it
const verifyButton = dialog.getByRole("button", { name: "Verify this device" });
await expect(verifyButton).toBeVisible();
await expect(content).toMatchScreenshot("verify-device-encryption-tab.png");
await verifyButton.click();

await util.verifyDevice(recoveryKey);

await expect(content).toMatchScreenshot("default-tab.png", {
mask: [content.getByTestId("deviceId"), content.getByTestId("sessionKey")],
});

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
// The bot bootstraps cross-signing, creates a key backup and sets up a recovery key
await createBot(page, homeserver, credentials);
});

test(
Expand Down Expand Up @@ -121,37 +85,4 @@ test.describe("Recovery section in Encryption tab", () => {
// Check that the current device is connected to key backup and the backup version is the expected one
await checkDeviceIsConnectedKeyBackup(app, "1", true);
});

// Test what happens if the cross-signing secrets are in secret storage but are not cached in the local DB.
//
// This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
// We simulate this case by deleting the cached secrets in the indexedDB.
test(
"should enter the recovery key when the secrets are not cached",
{ tag: "@screenshot" },
async ({ page, app, util }) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);

await util.openEncryptionTab();
// We ask the user to enter the recovery key
const dialog = util.getEncryptionTabContent();
const enterKeyButton = dialog.getByRole("button", { name: "Enter recovery key" });
await expect(enterKeyButton).toBeVisible();
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("out-of-sync-recovery.png");
await enterKeyButton.click();

// Fill the recovery key
await util.enterRecoveryKey(recoveryKey);
await expect(util.getEncryptionRecoverySection()).toMatchScreenshot("default-recovery.png");

// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);

// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
79 changes: 15 additions & 64 deletions src/components/views/settings/encryption/RecoveryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,23 @@
* Please see LICENSE files in the repository root for full details.
*/

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

import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { SettingsHeader } from "../SettingsHeader";
import { accessSecretStorage } from "../../../../SecurityManager";
import { SettingsSubheader } from "../SettingsSubheader";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";

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

interface RecoveryPanelProps {
/**
Expand All @@ -40,29 +37,18 @@ interface RecoveryPanelProps {
* This component allows the user to set up or change their recovery key.
*/
export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
const [state, setState] = useState<State>("loading");
const isMissingRecoveryKey = state === "missing_recovery_key";

const matrixClient = useMatrixClientContext();

const checkEncryption = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;

// Check if the user has a recovery key
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (!hasRecoveryKey) return setState("missing_recovery_key");

// Check if the secrets are cached
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
if (!secretsOk) return setState("secrets_not_cached");

setState("good");
}, [matrixClient]);

useEffect(() => {
checkEncryption();
}, [checkEncryption]);
const state = useAsyncMemo<State>(
async () => {
// Check if the user has a recovery key
const hasRecoveryKey = Boolean(await matrixClient.secretStorage.getDefaultKeyId());
if (hasRecoveryKey) return "good";
else return "missing_recovery_key";
},
[matrixClient],
"loading",
);
const isMissingRecoveryKey = state === "missing_recovery_key";

let content: JSX.Element;
switch (state) {
Expand All @@ -76,18 +62,6 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
</Button>
);
break;
case "secrets_not_cached":
content = (
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => await accessSecretStorage(checkEncryption)}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
);
break;
case "good":
content = (
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={() => onChangeRecoveryKeyClick(false)}>
Expand All @@ -105,33 +79,10 @@ export function RecoveryPanel({ onChangeRecoveryKeyClick }: RecoveryPanelProps):
label={_t("settings|encryption|recovery|title")}
/>
}
subHeading={<Subheader state={state} />}
subHeading={_t("settings|encryption|recovery|description")}
data-testid="recoveryPanel"
>
{content}
</SettingsSection>
);
}

interface SubheaderProps {
/**
* The state of the recovery panel.
*/
state: State;
}

/**
* The subheader for the recovery panel.
*/
function Subheader({ state }: SubheaderProps): JSX.Element {
// If the secrets are not cached, we display a warning message.
if (state !== "secrets_not_cached") return <>{_t("settings|encryption|recovery|description")}</>;

return (
<SettingsSubheader
label={_t("settings|encryption|recovery|description")}
state="error"
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { JSX } from "react";
import { Button } from "@vector-im/compound-web";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";

import { SettingsSection } from "../shared/SettingsSection";
import { _t } from "../../../../languageHandler";
import { SettingsSubheader } from "../SettingsSubheader";
import { accessSecretStorage } from "../../../../SecurityManager";

interface RecoveryPanelOutOfSyncProps {
/**
* Callback for when the user has finished entering their recovery key.
*/
onFinish: () => void;
}

/**
* This component is shown as part of the {@link EncryptionUserSettingsTab}, instead of the
* {@link RecoveryPanel}, when some of the user secrets are not cached in the local client.
*
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
* the client.
*/
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
return (
<SettingsSection
legacy={false}
heading={_t("settings|encryption|recovery|title")}
subHeading={
<SettingsSubheader
label={_t("settings|encryption|recovery|description")}
state="error"
stateMessage={_t("settings|encryption|recovery|key_storage_warning")}
/>
}
data-testid="recoveryPanel"
>
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => {
await accessSecretStorage();
onFinish();
}}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
</SettingsSection>
);
}
Loading

0 comments on commit b7f8623

Please sign in to comment.