diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index b0462db477..f742be70e4 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -98,7 +98,3 @@ limitations under the License. } } } - -.mx_CompleteSecurity_resetText { - padding-top: 20px; -} diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 9f1d0f4998..63e5a3de09 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -73,42 +73,33 @@ limitations under the License. margin-left: 20px; } +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + .mx_CreateSecretStorageDialog_recoveryKeyContainer { - width: 380px; - margin-left: auto; - margin-right: auto; + display: flex; } .mx_CreateSecretStorageDialog_recoveryKey { - font-weight: bold; - text-align: center; + width: 262px; padding: 20px; color: $info-plinth-fg-color; background-color: $info-plinth-bg-color; - border-radius: 6px; - word-spacing: 1em; - margin-bottom: 20px; + margin-right: 12px; } .mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; display: flex; - justify-content: space-between; align-items: center; } .mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { - width: 160px; - padding-left: 0px; - padding-right: 0px; + margin-right: 10px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; white-space: nowrap; } - -.mx_CreateSecretStorageDialog_continueSpinner { - margin-top: 33px; - text-align: right; -} - -.mx_CreateSecretStorageDialog_continueSpinner img { - width: 20px; - height: 20px; -} diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index d40f820ac0..c37d0f8bf5 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -30,8 +30,6 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; // operation ends. let secretStorageKeys = {}; let secretStorageBeingAccessed = false; -// Stores the 'passphraseOnly' option for the active storage access operation -let passphraseOnlyOption = null; function isCachingAllowed() { return ( @@ -101,7 +99,6 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); return await MatrixClientPeg.get().checkSecretStorageKey(key, info); }, - passphraseOnly: passphraseOnlyOption, }, /* className= */ null, /* isPriorityModal= */ false, @@ -216,27 +213,19 @@ export async function promptForBackupPassphrase() { * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {object} [opts] Named options - * @param {bool} [opts.forceReset] Reset secret storage even if it's already set up - * @param {object} [opts.withKeys] Map of key ID to key for SSSS keys that the client - * already has available. If a key is not supplied here, the user will be prompted. - * @param {bool} [opts.passphraseOnly] If true, do not prompt for recovery key or to reset keys + * @param {bool} [forceReset] Reset secret storage even if it's already set up */ -export async function accessSecretStorage( - func = async () => { }, opts = {}, -) { +export async function accessSecretStorage(func = async () => { }, forceReset = false) { const cli = MatrixClientPeg.get(); secretStorageBeingAccessed = true; - passphraseOnlyOption = opts.passphraseOnly; - secretStorageKeys = Object.assign({}, opts.withKeys || {}); try { - if (!await cli.hasSecretStorageKey() || opts.forceReset) { + if (!await cli.hasSecretStorageKey() || forceReset) { // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), { - force: opts.forceReset, + force: forceReset, }, null, /* priority = */ false, /* static = */ true, ); @@ -274,6 +263,5 @@ export async function accessSecretStorage( if (!isCachingAllowed()) { secretStorageKeys = {}; } - passphraseOnlyOption = null; } } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 192427d384..d7b79c2cfa 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -20,23 +20,25 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t} from '../../../../languageHandler'; +import {_t, _td} from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; -import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; -import DialogButtons from "../../../../components/views/elements/DialogButtons"; -import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; - +import PassphraseField from "../../../../components/views/auth/PassphraseField"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; const PHASE_MIGRATE = 2; -const PHASE_INTRO = 3; -const PHASE_SHOWKEY = 4; -const PHASE_STORING = 5; -const PHASE_CONFIRM_SKIP = 6; +const PHASE_PASSPHRASE = 3; +const PHASE_PASSPHRASE_CONFIRM = 4; +const PHASE_SHOWKEY = 5; +const PHASE_KEEPITSAFE = 6; +const PHASE_STORING = 7; +const PHASE_DONE = 8; +const PHASE_CONFIRM_SKIP = 9; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. /* * Walks the user through the process of creating a passphrase to guard Secure @@ -63,32 +65,34 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state = { phase: PHASE_LOADING, - downloaded: false, + passPhrase: '', + passPhraseValid: false, + passPhraseConfirm: '', copied: false, + downloaded: false, backupInfo: null, - backupInfoFetched: false, - backupInfoFetchError: null, backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password - // for /keys/device_signing/upload? (If we have an account password, we - // assume that it can) + // for /keys/device_signing/upload? canUploadKeysWithPasswordOnly: null, - canUploadKeyCheckInProgress: false, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - // No toggle for this: if we really don't want one, remove it & just hard code true + // status of the key backup toggle switch useKeyBackup: true, }; - if (props.accountPassword) { - // If we have an account password, we assume we can upload keys with - // just a password (otherwise leave it as null so we poll to check) - this.state.canUploadKeysWithPasswordOnly = true; - } - this._passphraseField = createRef(); - this.loadData(); + this._fetchBackupInfo(); + if (this.state.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. + this.state.canUploadKeysWithPasswordOnly = true; + } else { + this._queryKeyUploadAuth(); + } MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } @@ -105,11 +109,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); + const { force } = this.props; + const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE; + this.setState({ - backupInfoFetched: true, + phase, backupInfo, backupSigStatus, - backupInfoFetchError: null, }); return { @@ -117,25 +123,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({backupInfoFetchError: e}); + this.setState({phase: PHASE_LOADERROR}); } } async _queryKeyUploadAuth() { try { - this.setState({canUploadKeyCheckInProgress: true}); await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); // 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. console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); - this.setState({canUploadKeyCheckInProgress: false}); } catch (error) { if (!error.data || !error.data.flows) { console.log("uploadDeviceSigningKeys advertised no flows!"); - this.setState({ - canUploadKeyCheckInProgress: false, - }); return; } const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { @@ -143,18 +144,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); this.setState({ canUploadKeysWithPasswordOnly, - canUploadKeyCheckInProgress: false, }); } } - async _createRecoveryKey() { - this._recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); - this.setState({ - phase: PHASE_SHOWKEY, - }); - } - _onKeyBackupStatusChange = () => { if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); } @@ -163,6 +156,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._recoveryKeyNode = n; } + _onUseKeyBackupChange = (enabled) => { + this.setState({ + useKeyBackup: enabled, + }); + } + _onMigrateFormSubmit = (e) => { e.preventDefault(); if (this.state.backupSigStatus.usable) { @@ -172,15 +171,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onIntroContinueClick = () => { - this._createRecoveryKey(); - } - _onCopyClick = () => { const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, + phase: PHASE_KEEPITSAFE, }); } } @@ -190,8 +186,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { type: 'text/plain;charset=us-ascii', }); FileSaver.saveAs(blob, 'recovery-key.txt'); + this.setState({ downloaded: true, + phase: PHASE_KEEPITSAFE, }); } @@ -247,9 +245,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _bootstrapSecretStorage = async () => { this.setState({ - // we use LOADING here rather than STORING as STORING still shows the 'show key' - // screen which is not relevant: LOADING is just a generic spinner. - phase: PHASE_LOADING, + phase: PHASE_STORING, error: null, }); @@ -290,7 +286,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } - this.props.onFinished(true); + this.setState({ + phase: PHASE_DONE, + }); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ @@ -309,6 +307,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.props.onFinished(false); } + _onDone = () => { + this.props.onFinished(true); + } + _restoreBackup = async () => { // It's possible we'll need the backup key later on for bootstrapping, // so let's stash it here, rather than prompting for it twice. @@ -335,41 +337,88 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onShowKeyContinueClick = () => { - this._bootstrapSecretStorage(); - } - _onLoadRetryClick = () => { - this.loadData(); - } - - async loadData() { this.setState({phase: PHASE_LOADING}); - const proms = []; - - if (!this.state.backupInfoFetched) proms.push(this._fetchBackupInfo()); - if (this.state.canUploadKeysWithPasswordOnly === null) proms.push(this._queryKeyUploadAuth()); - - await Promise.all(proms); - if (this.state.canUploadKeysWithPasswordOnly === null || this.state.backupInfoFetchError) { - this.setState({phase: PHASE_LOADERROR}); - } else if (this.state.backupInfo && !this.props.force) { - this.setState({phase: PHASE_MIGRATE}); - } else { - this.setState({phase: PHASE_INTRO}); - } + this._fetchBackupInfo(); } _onSkipSetupClick = () => { this.setState({phase: PHASE_CONFIRM_SKIP}); } - _onGoBackClick = () => { - if (this.state.backupInfo && !this.props.force) { - this.setState({phase: PHASE_MIGRATE}); - } else { - this.setState({phase: PHASE_INTRO}); + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + this._recoveryKey = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = async (e) => { + 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_PASSPHRASE_CONFIRM}); + }; + + _onPassPhraseConfirmNextClick = async (e) => { + e.preventDefault(); + + if (this.state.passPhrase !== this.state.passPhraseConfirm) return; + + this._recoveryKey = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseValid: false, + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseValidate = (result) => { + this.setState({ + passPhraseValid: result.valid, + }); + }; + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); } _onAccountPasswordChange = (e) => { @@ -384,14 +433,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/riot-web/issues/11696 + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); let authPrompt; let nextCaption = _t("Next"); - if (!this.state.backupSigStatus.usable) { - authPrompt = null; - nextCaption = _t("Upload"); - } else if (this.state.canUploadKeysWithPasswordOnly && !this.props.accountPassword) { + if (this.state.canUploadKeysWithPasswordOnly) { authPrompt =
{_t("You'll need to authenticate with the server to confirm the upgrade.")} @@ -411,9 +463,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return
; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const Field = sdk.getComponent('views.elements.Field'); + + let matchText; + let changeText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + changeText = _t("Use a different passphrase?"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-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("That doesn't match."); + changeText = _t("Go back to set it again."); } + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch ={_t( - "Store your Recovery Key somewhere safe, it can be used to unlock your encrypted messages & data.", + "Your recovery key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your recovery passphrase.", + )}
+{_t( + "Keep a copy of it somewhere secure, like a password manager or even a safe.", )}
{this._recoveryKey.encodedPrivateKey}
{_t("Unable to query secret storage status")}
{_t( - "Create a Recovery Key to store encryption keys & secrets with your account data. " + - "If you lose access to this login you’ll need it to unlock your data.", + "You can now verify your other devices, " + + "and other users to keep your chats safe.", )}
-{_t("Unable to set up secret storage")}
{_t( @@ -224,67 +131,15 @@ export default class SetupEncryptionBody extends React.Component {