/* * Copyright 2025 Element Creations Ltd. * Copyright 2024 New Vector Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only * Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type MouseEventHandler, useState } from "react"; import { Breadcrumb, Button, ErrorMessage, Field, IconButton, Label, PasswordControl, Root, Text, } from "@vector-im/compound-web"; import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy"; import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid"; import { _t } from "../../../../languageHandler"; import { EncryptionCard } from "./EncryptionCard"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; import { copyPlaintext } from "../../../../utils/strings"; import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration.ts"; import { withSecretStorageKeyCache } from "../../../../SecurityManager"; import { EncryptionCardButtons } from "./EncryptionCardButtons"; import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx"; import DeviceListener, { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; /** * The possible states of the component. * - `inform_user`: The user is informed about the recovery key. * - `save_key_setup_flow`: The user is asked to save the new recovery key during the setup flow. * - `save_key_change_flow`: The user is asked to save the new recovery key during the change key flow. * - `confirm_key_setup_flow`: The user is asked to confirm the new recovery key during the set up flow. * - `confirm_key_change_flow`: The user is asked to confirm the new recovery key during the change key flow. */ type State = | "inform_user" | "save_key_setup_flow" | "save_key_change_flow" | "confirm_key_setup_flow" | "confirm_key_change_flow"; interface ChangeRecoveryKeyProps { /** * If true, the component will display the flow to change the recovery key. * If false,the component will display the flow to set up a new recovery key. */ userHasRecoveryKey: boolean; /** * Called when the recovery key is successfully changed. */ onFinish: () => void; /** * Called when the cancel button is clicked or when we go back in the breadcrumbs. */ onCancelClick: () => void; } /** * A component to set up or change the recovery key. */ export function ChangeRecoveryKey({ userHasRecoveryKey, onFinish, onCancelClick, }: ChangeRecoveryKeyProps): JSX.Element | null { const matrixClient = useMatrixClientContext(); // If the user is setting up recovery for the first time, we first show them a panel explaining what // "recovery" is about. Otherwise, we jump straight to showing the user the new key. const [state, setState] = useState(userHasRecoveryKey ? "save_key_change_flow" : "inform_user"); // We create a new recovery key, the recovery key will be displayed to the user const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []); // Waiting for the recovery key to be generated if (!recoveryKey) return null; let content: JSX.Element; switch (state) { case "inform_user": // Show a panel explaining what "recovery" is for, and what a recovery key does. content = ( setState("save_key_setup_flow")} onCancelClick={onCancelClick} /> ); break; case "save_key_setup_flow": case "save_key_change_flow": // Show a generated recovery key and ask the user to save it. content = ( setState((currentState) => currentState === "save_key_change_flow" ? "confirm_key_change_flow" : "confirm_key_setup_flow", ) } onCancelClick={onCancelClick} /> ); break; case "confirm_key_setup_flow": case "confirm_key_change_flow": // Ask the user to enter the recovery key they just saved to confirm it. content = ( { const crypto = matrixClient.getCrypto(); if (!crypto) return onFinish(); try { const deviceListener = DeviceListener.sharedInstance(); // we need to call keyStorageOutOfSyncNeedsBackupReset here because // deviceListener.whilePaused() sets its client to undefined, so // keyStorageOutOfSyncNeedsBackupReset won't be able to check // the backup state. const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(true); await deviceListener.whilePaused(async () => { // We need to enable the cache to avoid to prompt the user to enter the new key // when we will try to access the secret storage during the bootstrap await withSecretStorageKeyCache(async () => { await crypto.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey: async () => recoveryKey, }); // Reset the key backup if needed if (needsBackupReset) { await resetKeyBackupAndWait(crypto); } await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true }); }); }); // Record the fact that the user explicitly enabled recovery. await matrixClient.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: true }); onFinish(); } catch (e) { logErrorAndShowErrorDialog("Failed to set up secret storage", e); } }} submitButtonLabel={ state === "confirm_key_setup_flow" ? _t("settings|encryption|recovery|set_up_recovery_confirm_button") : _t("settings|encryption|recovery|change_recovery_confirm_button") } /> ); } const pages = [ _t("settings|encryption|title"), userHasRecoveryKey ? _t("settings|encryption|recovery|change_recovery_key") : _t("settings|encryption|recovery|set_up_recovery"), ]; const labels = getLabels(state); return ( <> {content} ); } type Labels = { /** * The title of the card. */ title: string; /** * The description of the card. */ description: string; }; /** * Get the header title and description for the given state. * @param state */ function getLabels(state: State): Labels { switch (state) { case "inform_user": return { title: _t("settings|encryption|recovery|set_up_recovery"), description: _t("settings|encryption|recovery|set_up_recovery_description", { changeRecoveryKeyButton: _t("settings|encryption|recovery|change_recovery_key"), }), }; case "save_key_setup_flow": return { title: _t("settings|encryption|recovery|set_up_recovery_save_key_title"), description: _t("settings|encryption|recovery|set_up_recovery_save_key_description"), }; case "save_key_change_flow": return { title: _t("settings|encryption|recovery|change_recovery_key_title"), description: _t("settings|encryption|recovery|change_recovery_key_description"), }; case "confirm_key_setup_flow": return { title: _t("settings|encryption|recovery|set_up_recovery_confirm_title"), description: _t("settings|encryption|recovery|set_up_recovery_confirm_description"), }; case "confirm_key_change_flow": return { title: _t("settings|encryption|recovery|change_recovery_confirm_title"), description: _t("settings|encryption|recovery|change_recovery_confirm_description"), }; } } interface InformationPanelProps { /** * Called when the continue button is clicked. */ onContinueClick: MouseEventHandler; /** * Called when the cancel button is clicked. */ onCancelClick: MouseEventHandler; } /** * The panel to display information about the recovery key. */ function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelProps): JSX.Element { return ( <> {_t("settings|encryption|recovery|set_up_recovery_secondary_description")} ); } interface KeyPanelProps { /** * Called when the confirm button is clicked. */ onConfirmClick: MouseEventHandler; /** * Called when the cancel button is clicked. */ onCancelClick: MouseEventHandler; /** * The recovery key to display. */ recoveryKey: string; } /** * The panel to display the recovery key. */ function KeyPanel({ recoveryKey, onConfirmClick, onCancelClick }: KeyPanelProps): JSX.Element { return ( <>
{_t("settings|encryption|recovery|save_key_title")}
{recoveryKey} {_t("settings|encryption|recovery|save_key_description")}
copyPlaintext(recoveryKey)}>
); } interface KeyFormProps { /** * Called when the cancel button is clicked. */ onCancelClick: MouseEventHandler; /** * Called when the form is submitted. */ onSubmit: () => Promise; /** * The recovery key to confirm. */ recoveryKey: string; /** * The label for the submit button. */ submitButtonLabel: string; } /** * The form to confirm the recovery key. * The finish button is disabled until the key is filled and valid. * The entered key is valid if it matches the recovery key. */ function KeyForm({ onCancelClick, onSubmit, recoveryKey, submitButtonLabel }: KeyFormProps): JSX.Element { // Undefined by default, as the key is not filled yet const [isKeyValid, setIsKeyValid] = useState(); const [isKeyChangeInProgress, setIsKeyChangeInProgress] = useState(false); const isKeyInvalidAndFilled = isKeyValid === false; return ( { evt.preventDefault(); if (isKeyChangeInProgress) { // Don't allow repeated attempts. return; } setIsKeyChangeInProgress(true); onSubmit().finally(() => { setIsKeyChangeInProgress(false); }); }} onChange={async (evt) => { evt.preventDefault(); evt.stopPropagation(); // We don't have any file in the form, we can cast it as string safely const filledKey = new FormData(evt.currentTarget).get("recoveryKey") as string | ""; setIsKeyValid(filledKey.trim() === recoveryKey); }} > {isKeyInvalidAndFilled && ( {_t("settings|encryption|recovery|enter_key_error")} )} ); }