diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e41..b0462db477a 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -98,3 +98,7 @@ 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 63e5a3de093..9f1d0f49983 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -73,33 +73,42 @@ limitations under the License. margin-left: 20px; } -.mx_CreateSecretStorageDialog_recoveryKeyHeader { - margin-bottom: 1em; -} - .mx_CreateSecretStorageDialog_recoveryKeyContainer { - display: flex; + width: 380px; + margin-left: auto; + margin-right: auto; } .mx_CreateSecretStorageDialog_recoveryKey { - width: 262px; + font-weight: bold; + text-align: center; padding: 20px; color: $info-plinth-fg-color; background-color: $info-plinth-bg-color; - margin-right: 12px; + border-radius: 6px; + word-spacing: 1em; + margin-bottom: 20px; } .mx_CreateSecretStorageDialog_recoveryKeyButtons { - flex: 1; display: flex; + justify-content: space-between; align-items: center; } .mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { - margin-right: 10px; + width: 160px; + padding-left: 0px; + padding-right: 0px; + white-space: nowrap; } -.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 c37d0f8bf5d..d40f820ac03 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -30,6 +30,8 @@ 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 ( @@ -99,6 +101,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); return await MatrixClientPeg.get().checkSecretStorageKey(key, info); }, + passphraseOnly: passphraseOnlyOption, }, /* className= */ null, /* isPriorityModal= */ false, @@ -213,19 +216,27 @@ export async function promptForBackupPassphrase() { * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {bool} [forceReset] Reset secret storage even if it's already set up + * @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 */ -export async function accessSecretStorage(func = async () => { }, forceReset = false) { +export async function accessSecretStorage( + func = async () => { }, opts = {}, +) { const cli = MatrixClientPeg.get(); secretStorageBeingAccessed = true; + passphraseOnlyOption = opts.passphraseOnly; + secretStorageKeys = Object.assign({}, opts.withKeys || {}); try { - if (!await cli.hasSecretStorageKey() || forceReset) { + if (!await cli.hasSecretStorageKey() || opts.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: forceReset, + force: opts.forceReset, }, null, /* priority = */ false, /* static = */ true, ); @@ -263,5 +274,6 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f 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 d7b79c2cfaa..192427d3840 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -20,25 +20,23 @@ import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; -import {_t, _td} from '../../../../languageHandler'; +import {_t} from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; -import PassphraseField from "../../../../components/views/auth/PassphraseField"; +import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; + const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; const PHASE_MIGRATE = 2; -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. +const PHASE_INTRO = 3; +const PHASE_SHOWKEY = 4; +const PHASE_STORING = 5; +const PHASE_CONFIRM_SKIP = 6; /* * Walks the user through the process of creating a passphrase to guard Secure @@ -65,35 +63,33 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.state = { phase: PHASE_LOADING, - passPhrase: '', - passPhraseValid: false, - passPhraseConfirm: '', - copied: false, downloaded: false, + copied: 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? + // for /keys/device_signing/upload? (If we have an account password, we + // assume that it can) canUploadKeysWithPasswordOnly: null, + canUploadKeyCheckInProgress: false, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - // status of the key backup toggle switch + // No toggle for this: if we really don't want one, remove it & just hard code true useKeyBackup: true, }; - this._passphraseField = createRef(); - - 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. + 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; - } else { - this._queryKeyUploadAuth(); } + this._passphraseField = createRef(); + + this.loadData(); + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } @@ -109,13 +105,11 @@ 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({ - phase, + backupInfoFetched: true, backupInfo, backupSigStatus, + backupInfoFetchError: null, }); return { @@ -123,20 +117,25 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({phase: PHASE_LOADERROR}); + this.setState({backupInfoFetchError: e}); } } 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 => { @@ -144,10 +143,18 @@ 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(); } @@ -156,12 +163,6 @@ 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) { @@ -171,12 +172,15 @@ 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, }); } } @@ -186,10 +190,8 @@ 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, }); } @@ -245,7 +247,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _bootstrapSecretStorage = async () => { this.setState({ - phase: PHASE_STORING, + // 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, error: null, }); @@ -286,9 +290,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } - this.setState({ - phase: PHASE_DONE, - }); + this.props.onFinished(true); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ @@ -307,10 +309,6 @@ 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. @@ -337,88 +335,41 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onLoadRetryClick = () => { - this.setState({phase: PHASE_LOADING}); - this._fetchBackupInfo(); + _onShowKeyContinueClick = () => { + this._bootstrapSecretStorage(); } - _onSkipSetupClick = () => { - this.setState({phase: PHASE_CONFIRM_SKIP}); - } - - _onSetUpClick = () => { - this.setState({phase: PHASE_PASSPHRASE}); + _onLoadRetryClick = () => { + this.loadData(); } - _onSkipPassPhraseClick = async () => { - this._recoveryKey = - await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); - this.setState({ - copied: false, - downloaded: false, - phase: PHASE_SHOWKEY, - }); - } + async loadData() { + this.setState({phase: PHASE_LOADING}); + const proms = []; - _onPassPhraseNextClick = async (e) => { - e.preventDefault(); - if (!this._passphraseField.current) return; // unmounting + if (!this.state.backupInfoFetched) proms.push(this._fetchBackupInfo()); + if (this.state.canUploadKeysWithPasswordOnly === null) proms.push(this._queryKeyUploadAuth()); - 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; + 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.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, - }); + _onSkipSetupClick = () => { + this.setState({phase: PHASE_CONFIRM_SKIP}); } - _onPassPhraseConfirmChange = (e) => { - this.setState({ - passPhraseConfirm: e.target.value, - }); + _onGoBackClick = () => { + if (this.state.backupInfo && !this.props.force) { + this.setState({phase: PHASE_MIGRATE}); + } else { + this.setState({phase: PHASE_INTRO}); + } } _onAccountPasswordChange = (e) => { @@ -433,12 +384,14 @@ 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.canUploadKeysWithPasswordOnly) { + if (!this.state.backupSigStatus.usable) { + authPrompt = null; + nextCaption = _t("Upload"); + } else if (this.state.canUploadKeysWithPasswordOnly && !this.props.accountPassword) { authPrompt =
{_t("You'll need to authenticate with the server to confirm the upgrade.")} @@ -463,9 +411,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return