/* Copyright 2024 New Vector Ltd. Copyright 2019, 2020 , 2023 The Matrix.org Foundation C.I.C. Copyright 2018, 2019 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, { createRef } from "react"; import FileSaver from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; import { AuthDict, CrossSigningKeys, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix"; import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import classNames from "classnames"; import CheckmarkIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { _t, _td } from "../../../../languageHandler"; import Modal from "../../../../Modal"; import { copyNode } from "../../../../utils/strings"; import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; import StyledRadioButton from "../../../../components/views/elements/StyledRadioButton"; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import { getSecureBackupSetupMethods, isSecureBackupRequired, SecureBackupSetupMethod, } from "../../../../utils/WellKnownUtils"; import { ModuleRunner } from "../../../../modules/ModuleRunner"; import Field from "../../../../components/views/elements/Field"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import Spinner from "../../../../components/views/elements/Spinner"; import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog"; import { IValidationResult } from "../../../../components/views/elements/Validation"; import PassphraseConfirmField from "../../../../components/views/auth/PassphraseConfirmField"; import { initialiseDehydration } from "../../../../utils/device/dehydration"; // I made a mistake while converting this and it has to be fixed! enum Phase { Loading = "loading", LoadError = "load_error", ChooseKeyPassphrase = "choose_key_passphrase", Passphrase = "passphrase", PassphraseConfirm = "passphrase_confirm", ShowKey = "show_key", Storing = "storing", Stored = "stored", ConfirmSkip = "confirm_skip", } const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. interface IProps { hasCancel?: boolean; accountPassword?: string; forceReset?: boolean; resetCrossSigning?: boolean; onFinished(ok?: boolean): void; } interface IState { phase: Phase; passPhrase: string; passPhraseValid: boolean; passPhraseConfirm: string; copied: boolean; downloaded: boolean; setPassphrase: boolean; // does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? canUploadKeysWithPasswordOnly: boolean | null; accountPassword: string; accountPasswordCorrect: boolean | null; canSkip: boolean; passPhraseKeySelected: string; error?: boolean; } /** * Walks the user through the process of creating a 4S passphrase and bootstrapping secret storage. * * If the user already has a key backup, follows a "migration" flow (aka "Upgrade your encryption") which * prompts the user to enter their backup decryption password (a Curve25519 private key, possibly derived * from a passphrase), and uses that as the (AES) 4S encryption key. */ export default class CreateSecretStorageDialog extends React.PureComponent { public static defaultProps: Partial = { hasCancel: true, forceReset: false, resetCrossSigning: false, }; private recoveryKey?: GeneratedSecretStorageKey; private recoveryKeyNode = createRef(); private passphraseField = createRef(); public constructor(props: IProps) { super(props); const cli = MatrixClientPeg.safeGet(); let passPhraseKeySelected: SecureBackupSetupMethod; const setupMethods = getSecureBackupSetupMethods(cli); if (setupMethods.includes(SecureBackupSetupMethod.Key)) { passPhraseKeySelected = SecureBackupSetupMethod.Key; } else { passPhraseKeySelected = SecureBackupSetupMethod.Passphrase; } const accountPassword = props.accountPassword || ""; let canUploadKeysWithPasswordOnly: boolean | null = null; if (accountPassword) { // If we have an account password in memory, let's simplify and // assume it means password auth is also supported for device // signing key upload as well. This avoids hitting the server to // test auth flows, which may be slow under high load. canUploadKeysWithPasswordOnly = true; } const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey(); const phase = keyFromCustomisations ? Phase.Loading : Phase.ChooseKeyPassphrase; this.state = { phase, passPhrase: "", passPhraseValid: false, passPhraseConfirm: "", copied: false, downloaded: false, setPassphrase: false, // does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? accountPasswordCorrect: null, canSkip: !isSecureBackupRequired(cli), canUploadKeysWithPasswordOnly, passPhraseKeySelected, accountPassword, }; } public componentDidMount(): void { const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.createSecretStorageKey(); if (keyFromCustomisations) this.initExtension(keyFromCustomisations); if (this.state.canUploadKeysWithPasswordOnly === null) { this.queryKeyUploadAuth(); } } private initExtension(keyFromCustomisations: Uint8Array): void { logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step"); this.recoveryKey = { privateKey: keyFromCustomisations, }; this.bootstrapSecretStorage(); } private async queryKeyUploadAuth(): Promise { try { await MatrixClientPeg.safeGet().uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); } catch (error) { if (!(error instanceof MatrixError) || !error.data || !error.data.flows) { logger.log("uploadDeviceSigningKeys advertised no flows!"); return; } const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => { return f.stages.length === 1 && f.stages[0] === "m.login.password"; }); this.setState({ canUploadKeysWithPasswordOnly, }); } } private onKeyPassphraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseKeySelected: e.target.value, }); }; private onChooseKeyPassphraseFormSubmit = async (): Promise => { if (this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key) { this.recoveryKey = await MatrixClientPeg.safeGet().getCrypto()!.createRecoveryKeyFromPassphrase(); this.setState({ copied: false, downloaded: false, setPassphrase: false, phase: Phase.ShowKey, }); } else { this.setState({ copied: false, downloaded: false, phase: Phase.Passphrase, }); } }; private onCopyClick = (): void => { const successful = copyNode(this.recoveryKeyNode.current); if (successful) { this.setState({ copied: true, }); } }; private onDownloadClick = (): void => { if (!this.recoveryKey) return; const blob = new Blob([this.recoveryKey.encodedPrivateKey!], { type: "text/plain;charset=us-ascii", }); FileSaver.saveAs(blob, "security-key.txt"); this.setState({ downloaded: true, }); }; private doBootstrapUIAuth = async ( makeRequest: (authData: AuthDict) => Promise>, ): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { await makeRequest({ type: "m.login.password", identifier: { type: "m.id.user", user: MatrixClientPeg.safeGet().getSafeUserId(), }, password: this.state.accountPassword, }); } else { const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("auth|uia|sso_title"), body: _t("auth|uia|sso_preauth_body"), continueText: _t("auth|sso"), continueKind: "primary", }, [SSOAuthEntry.PHASE_POSTAUTH]: { title: _t("encryption|confirm_encryption_setup_title"), body: _t("encryption|confirm_encryption_setup_body"), continueText: _t("action|confirm"), continueKind: "primary", }, }; const { finished } = Modal.createDialog(InteractiveAuthDialog, { title: _t("encryption|bootstrap_title"), matrixClient: MatrixClientPeg.safeGet(), makeRequest, aestheticsForStagePhases: { [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, }, }); const [confirmed] = await finished; if (!confirmed) { throw new Error("Cross-signing key upload auth canceled"); } } }; private bootstrapSecretStorage = async (): Promise => { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto()!; const { forceReset, resetCrossSigning } = this.props; let backupInfo; // First, unless we know we want to do a reset, we see if there is an existing key backup if (!forceReset) { try { this.setState({ phase: Phase.Loading }); backupInfo = await cli.getKeyBackupVersion(); } catch (e) { logger.error("Error fetching backup data from server", e); this.setState({ phase: Phase.LoadError }); return; } } this.setState({ phase: Phase.Storing, error: undefined, }); try { if (forceReset) { /* Resetting cross-signing requires secret storage to be reset * (otherwise it will try to store the cross-signing keys in the * old secret storage, and may prompt for the old key, which is * probably not available), and resetting key backup requires * cross-signing to be reset (so that the new backup can be * signed by the new cross-signing key). So we reset secret * storage first, then cross-signing, then key backup. */ logger.log("Forcing secret storage reset"); await crypto.bootstrapSecretStorage({ createSecretStorageKey: async () => this.recoveryKey!, setupNewSecretStorage: true, }); if (resetCrossSigning) { logger.log("Resetting cross signing"); await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys: this.doBootstrapUIAuth, setupNewCrossSigning: true, }); } logger.log("Resetting key backup"); await crypto.resetKeyBackup(); } else { // For password authentication users after 2020-09, this cross-signing // step will be a no-op since it is now setup during registration or login // when needed. We should keep this here to cover other cases such as: // * Users with existing sessions prior to 2020-09 changes // * SSO authentication users which require interactive auth to upload // keys (and also happen to skip all post-authentication flows at the // moment via token login) await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys: this.doBootstrapUIAuth, }); await crypto.bootstrapSecretStorage({ createSecretStorageKey: async () => this.recoveryKey!, setupNewKeyBackup: !backupInfo, }); } await initialiseDehydration(true); this.setState({ phase: Phase.Stored, }); } catch (e) { this.setState({ error: true }); logger.error("Error bootstrapping secret storage", e); } }; private onCancel = (): void => { this.props.onFinished(false); }; private onLoadRetryClick = (): void => { this.bootstrapSecretStorage(); }; private onShowKeyContinueClick = (): void => { this.bootstrapSecretStorage(); }; private onCancelClick = (): void => { this.setState({ phase: Phase.ConfirmSkip }); }; private onGoBackClick = (): void => { this.setState({ phase: Phase.ChooseKeyPassphrase }); }; private onPassPhraseNextClick = async (e: React.FormEvent): Promise => { e.preventDefault(); if (!this.passphraseField.current) return; // unmounting await this.passphraseField.current.validate({ allowEmpty: false }); if (!this.passphraseField.current.state.valid) { this.passphraseField.current.focus(); this.passphraseField.current.validate({ allowEmpty: false, focused: true }); return; } this.setState({ phase: Phase.PassphraseConfirm }); }; private onPassPhraseConfirmNextClick = async (e: React.FormEvent): Promise => { e.preventDefault(); if (this.state.passPhrase !== this.state.passPhraseConfirm) return; this.recoveryKey = await MatrixClientPeg.safeGet() .getCrypto()! .createRecoveryKeyFromPassphrase(this.state.passPhrase); this.setState({ copied: false, downloaded: false, setPassphrase: true, phase: Phase.ShowKey, }); }; private onSetAgainClick = (): void => { this.setState({ passPhrase: "", passPhraseValid: false, passPhraseConfirm: "", phase: Phase.Passphrase, }); }; private onPassPhraseValidate = (result: IValidationResult): void => { this.setState({ passPhraseValid: !!result.valid, }); }; private onPassPhraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhrase: e.target.value, }); }; private onPassPhraseConfirmChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseConfirm: e.target.value, }); }; private renderOptionKey(): JSX.Element { return (
{_t("settings|key_backup|setup_secure_backup|generate_security_key_title")}
{_t("settings|key_backup|setup_secure_backup|generate_security_key_description")}
); } private renderOptionPassphrase(): JSX.Element { return (
{_t("settings|key_backup|setup_secure_backup|enter_phrase_title")}
{_t("settings|key_backup|setup_secure_backup|use_phrase_only_you_know")}
); } private renderPhaseChooseKeyPassphrase(): JSX.Element { const setupMethods = getSecureBackupSetupMethods(MatrixClientPeg.safeGet()); const optionKey = setupMethods.includes(SecureBackupSetupMethod.Key) ? this.renderOptionKey() : null; const optionPassphrase = setupMethods.includes(SecureBackupSetupMethod.Passphrase) ? this.renderOptionPassphrase() : null; return (

{_t("settings|key_backup|setup_secure_backup|description")}

{optionKey} {optionPassphrase}
); } private renderPhasePassPhrase(): JSX.Element { return (

{_t("settings|key_backup|setup_secure_backup|enter_phrase_description")}

); } private renderPhasePassPhraseConfirm(): JSX.Element { let matchText; let changeText; if (this.state.passPhraseConfirm === this.state.passPhrase) { matchText = _t("settings|key_backup|setup_secure_backup|pass_phrase_match_success"); changeText = _t("settings|key_backup|setup_secure_backup|use_different_passphrase"); } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { // only tell them they're wrong if they've actually gone wrong. // Security conscious readers will note that if you left element-web unattended // on this screen, this would make it easy for a malicious person to guess // your passphrase one letter at a time, but they could get this faster by // just opening the browser's developer tools and reading it. // Note that not having typed anything at all will not hit this clause and // fall through so empty box === no hint. matchText = _t("settings|key_backup|setup_secure_backup|pass_phrase_match_failed"); changeText = _t("settings|key_backup|setup_secure_backup|set_phrase_again"); } let passPhraseMatch: JSX.Element | undefined; if (matchText) { passPhraseMatch = (
{matchText}
{changeText}
); } return (

{_t("settings|key_backup|setup_secure_backup|enter_phrase_to_confirm")}

{passPhraseMatch}
); } private renderPhaseShowKey(): JSX.Element { let continueButton: JSX.Element; if (this.state.phase === Phase.ShowKey) { continueButton = ( ); } else { continueButton = (
); } return (

{_t("settings|key_backup|setup_secure_backup|security_key_safety_reminder")}

{this.recoveryKey?.encodedPrivateKey}
{_t("action|download")} {_t("settings|key_backup|setup_secure_backup|download_or_copy", { downloadButton: "", copyButton: "", })} {this.state.copied ? _t("common|copied") : _t("action|copy")}
{continueButton}
); } private renderBusyPhase(): JSX.Element { return (
); } private renderStoredPhase(): JSX.Element { return ( <>

{_t("settings|key_backup|setup_secure_backup|backup_setup_success_description")}

this.props.onFinished(true)} hasCancel={false} /> ); } private renderPhaseLoadError(): JSX.Element { return (

{_t("settings|key_backup|setup_secure_backup|secret_storage_query_failure")}

); } private renderPhaseSkipConfirm(): JSX.Element { return (

{_t("settings|key_backup|setup_secure_backup|cancel_warning")}

{_t("settings|key_backup|setup_secure_backup|settings_reminder")}

); } private titleForPhase(phase: Phase): string { switch (phase) { case Phase.ChooseKeyPassphrase: return _t("encryption|set_up_toast_title"); case Phase.Passphrase: return _t("settings|key_backup|setup_secure_backup|title_set_phrase"); case Phase.PassphraseConfirm: return _t("settings|key_backup|setup_secure_backup|title_confirm_phrase"); case Phase.ConfirmSkip: return _t("common|are_you_sure"); case Phase.ShowKey: return _t("settings|key_backup|setup_secure_backup|title_save_key"); case Phase.Storing: return _t("encryption|bootstrap_title"); case Phase.Stored: return _t("settings|key_backup|setup_secure_backup|backup_setup_success_title"); default: return ""; } } private get topComponent(): React.ReactNode | null { if (this.state.phase === Phase.Stored) { return ; } return null; } private get classNames(): string { return classNames("mx_CreateSecretStorageDialog", { mx_SuccessDialog: this.state.phase === Phase.Stored, }); } public render(): React.ReactNode { let content; if (this.state.error) { content = (

{_t("settings|key_backup|setup_secure_backup|unable_to_setup")}

); } else { switch (this.state.phase) { case Phase.Loading: content = this.renderBusyPhase(); break; case Phase.LoadError: content = this.renderPhaseLoadError(); break; case Phase.ChooseKeyPassphrase: content = this.renderPhaseChooseKeyPassphrase(); break; case Phase.Passphrase: content = this.renderPhasePassPhrase(); break; case Phase.PassphraseConfirm: content = this.renderPhasePassPhraseConfirm(); break; case Phase.ShowKey: content = this.renderPhaseShowKey(); break; case Phase.Storing: content = this.renderBusyPhase(); break; case Phase.Stored: content = this.renderStoredPhase(); break; case Phase.ConfirmSkip: content = this.renderPhaseSkipConfirm(); break; } } let titleClass: string | string[] | undefined; switch (this.state.phase) { case Phase.Passphrase: case Phase.PassphraseConfirm: titleClass = [ "mx_CreateSecretStorageDialog_titleWithIcon", "mx_CreateSecretStorageDialog_securePhraseTitle", ]; break; case Phase.ShowKey: titleClass = [ "mx_CreateSecretStorageDialog_titleWithIcon", "mx_CreateSecretStorageDialog_secureBackupTitle", ]; break; case Phase.ChooseKeyPassphrase: titleClass = "mx_CreateSecretStorageDialog_centeredTitle"; break; } return (
{content}
); } }