From 3d7137d4adeb4fa69742605e1828c53dabb5a2e2 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 24 Jan 2020 19:11:57 +0000 Subject: [PATCH] Setup flow for cross-signing on login / registration Still outstanding: * Keep password from login / registration * Confirmation on skip button Fixes https://github.com/vector-im/riot-web/issues/11902 --- res/css/_common.scss | 18 ++++--- res/css/views/auth/_AuthBody.scss | 9 ++-- .../_CreateSecretStorageDialog.scss | 4 ++ .../CreateSecretStorageDialog.js | 39 +++++++++++---- src/components/structures/MatrixChat.js | 35 +++++++++++--- src/components/structures/RoomView.js | 2 +- src/components/structures/auth/E2eSetup.js | 48 +++++++++++++++++++ src/components/views/auth/AuthBody.js | 4 ++ .../keybackup/RestoreKeyBackupDialog.js | 25 ++++++++++ 9 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 src/components/structures/auth/E2eSetup.js diff --git a/res/css/_common.scss b/res/css/_common.scss index abc57a95ed..b92a618504 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -386,7 +386,13 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { text-align: right; } -.mx_Dialog button, .mx_Dialog input[type="submit"] { +/* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied + * to them that no button anywhere else in the app gets by default. In practice, buttons in other places + * in the app look the same by being AccessibleButtons, or possibly by having explict button classes. + * We should go through and have one consistent set of styles for buttons throughout the app. + * For now, I am duplicating the selectors here for mx_Dialog and mx_DialogButtons. + */ +.mx_Dialog button, .mx_Dialog input[type="submit"], .mx_Dialog_buttons button, .mx_Dialog_buttons input[type="submit"] { @mixin mx_DialogButton; margin-left: 0px; margin-right: 8px; @@ -402,27 +408,27 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { margin-right: 0px; } -.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover { +.mx_Dialog button:hover, .mx_Dialog input[type="submit"]:hover, .mx_Dialog_buttons button:hover, .mx_Dialog_buttons input[type="submit"]:hover { @mixin mx_DialogButton_hover; } -.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus { +.mx_Dialog button:focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:focus, .mx_Dialog_buttons input[type="submit"]:focus { filter: brightness($focus-brightness); } -.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary { +.mx_Dialog button.mx_Dialog_primary, .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button.mx_Dialog_primary, .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: $accent-fg-color; background-color: $accent-color; min-width: 156px; } -.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger { +.mx_Dialog button.danger, .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger, .mx_Dialog_buttons input[type="submit"].danger { background-color: $warning-color; border: solid 1px $warning-color; color: $accent-fg-color; } -.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled { +.mx_Dialog button:disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:disabled, .mx_Dialog_buttons input[type="submit"]:disabled { background-color: $light-fg-color; border: solid 1px $light-fg-color; opacity: 0.7; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index d342de6d75..51b9775811 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -15,13 +15,10 @@ limitations under the License. */ .mx_AuthBody { - width: 500px; background-color: $authpage-body-bg-color; border-radius: 0 4px 4px 0; padding: 25px 60px; box-sizing: border-box; - font-size: 12px; - color: $authpage-secondary-color; h2 { font-size: 24px; @@ -99,6 +96,12 @@ limitations under the License. border-radius: 4px; } +.mx_AuthBody_loginRegister { + width: 500px; + font-size: 12px; + color: $authpage-secondary-color; +} + .mx_AuthBody_editServerDetails { padding-left: 1em; font-size: 12px; diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index ed5aaa05a3..53e82670e1 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -78,6 +78,10 @@ limitations under the License. align-items: center; } +.mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { + margin-right: 10px; +} + .mx_CreateSecretStorageDialog_recoveryKeyButtons button { flex: 1; white-space: nowrap; diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 8fd881fc32..92ede334d0 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { scorePassword } from '../../../../utils/PasswordScorer'; @@ -52,6 +53,14 @@ function selectText(target) { * Secret Storage in account data. */ export default class CreateSecretStorageDialog extends React.PureComponent { + static propTypes = { + hasCancel: PropTypes.bool, + }; + + defaultProps = { + hasCancel: true, + }; + constructor(props) { super(props); @@ -82,9 +91,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._fetchBackupInfo(); this._queryKeyUploadAuth(); + + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } componentWillUnmount() { + MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); if (this._setZxcvbnResultTimeout !== null) { clearTimeout(this._setZxcvbnResultTimeout); } @@ -92,7 +104,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { async _fetchBackupInfo() { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + const backupSigStatus = ( + // we may not have started crypto yet, in which case we definitely don't trust the backup + MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) + ); const phase = backupInfo ? (backupSigStatus.usable ? PHASE_MIGRATE : PHASE_RESTORE_KEY_BACKUP) : @@ -127,6 +142,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } + _onKeyBackupStatusChange = () => { + this._fetchBackupInfo(); + } + _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; } @@ -229,7 +248,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _onRestoreKeyBackupClick = () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); Modal.createTrackedDialog( - 'Restore Backup', '', RestoreKeyBackupDialog, null, null, + 'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null, /* priority = */ false, /* static = */ true, ); } @@ -411,6 +430,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _renderPhasePassPhrase() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let strengthMeter; let helpText; @@ -472,9 +492,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
{_t("Advanced")} -

+

; } @@ -554,6 +574,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); } + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return

{_t( "Your recovery key is a safety net - you can use it to restore " + @@ -572,12 +593,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { {this._encodedRecoveryKey}

- - +
@@ -740,7 +761,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { onFinished={this.props.onFinished} title={this._titleForPhase(this.state.phase)} headerImage={headerImage} - hasCancel={[PHASE_PASSPHRASE].includes(this.state.phase)} + hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} >
{content} diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0486ce764c..b4b38d7617 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -89,12 +89,15 @@ export const VIEWS = { // showing flow to trust this new device with cross-signing COMPLETE_SECURITY: 6, + // flow to setup SSSS / cross-signing on this account + E2E_SETUP: 7, + // we are logged in with an active matrix client. - LOGGED_IN: 7, + LOGGED_IN: 8, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT: 8, + SOFT_LOGOUT: 9, }; // Actions that are redirected through the onboarding process prior to being @@ -657,7 +660,9 @@ export default createReactClass({ if ( !Lifecycle.isSoftLogout() && this.state.view !== VIEWS.LOGIN && - this.state.view !== VIEWS.COMPLETE_SECURITY + this.state.view !== VIEWS.REGISTER && + this.state.view !== VIEWS.COMPLETE_SECURITY && + this.state.view !== VIEWS.E2E_SETUP ) { this._onLoggedIn(); } @@ -1724,6 +1729,11 @@ export default createReactClass({ this.showScreen("forgot_password"); }, + onRegisterFlowComplete: function(credentials) { + this.onUserCompletedLoginFlow(); + return this.onRegistered(credentials); + }, + // returns a promise which resolves to the new MatrixClient onRegistered: function(credentials) { return Lifecycle.setLoggedIn(credentials); @@ -1847,12 +1857,18 @@ export default createReactClass({ if (masterKeyInStorage) { this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); + } else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + // This will only work if the feature is set to 'enable' in the config, + // since it's too early in the lifecycle for users to have turned the + // labs flag on. + this.setStateForNewView({ view: VIEWS.E2E_SETUP }); } else { this._onLoggedIn(); } }, - onCompleteSecurityFinished() { + // complete security / e2e setup has finished + onCompleteSecurityE2eSetupFinished() { this._onLoggedIn(); }, @@ -1872,7 +1888,14 @@ export default createReactClass({ const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); view = ( + ); + } else if (this.state.view === VIEWS.E2E_SETUP) { + const E2eSetup = sdk.getComponent('structures.auth.E2eSetup'); + view = ( + ); } else if (this.state.view === VIEWS.POST_REGISTRATION) { @@ -1939,7 +1962,7 @@ export default createReactClass({ email={this.props.startingFragmentQueryParams.email} brand={this.props.config.brand} makeRegistrationUrl={this._makeRegistrationUrl} - onLoggedIn={this.onRegistered} + onLoggedIn={this.onRegisterFlowComplete} onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} {...this.getServerProperties()} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5c243f04bc..60fff5f1e3 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -766,7 +766,7 @@ export default createReactClass({ onUserVerificationChanged: function(userId, _trustStatus) { const room = this.state.room; - if (!room.currentState.getMember(userId)) { + if (!room || !room.currentState.getMember(userId)) { return; } this._updateE2EStatus(room); diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js new file mode 100644 index 0000000000..a5f4ff933b --- /dev/null +++ b/src/components/structures/auth/E2eSetup.js @@ -0,0 +1,48 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import AsyncWrapper from '../../../AsyncWrapper'; +import * as sdk from '../../../index'; + +export default class E2eSetup extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + }; + + constructor() { + super(); + // awkwardly indented because https://github.com/eslint/eslint/issues/11310 + this._createStorageDialogPromise = + import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"); + } + + render() { + const AuthPage = sdk.getComponent("auth.AuthPage"); + const AuthBody = sdk.getComponent("auth.AuthBody"); + return ( + + + + + + ); + } +} diff --git a/src/components/views/auth/AuthBody.js b/src/components/views/auth/AuthBody.js index fe20d76afb..b74b7d866a 100644 --- a/src/components/views/auth/AuthBody.js +++ b/src/components/views/auth/AuthBody.js @@ -33,6 +33,10 @@ export default class AuthBody extends React.PureComponent { const classes = { 'mx_AuthBody': true, 'mx_AuthBody_noHeader': !this.props.header, + // XXX The login pages all use a smaller fonts size but we don't want this + // for subsequent auth screens like the e2e setup. Doing this a terrible way + // for now. + 'mx_AuthBody_loginRegister': this.props.header, }; return
diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 77fdee5e8a..0c432ba542 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; @@ -32,6 +33,16 @@ const RESTORE_TYPE_SECRET_STORAGE = 2; * Dialog for restoring e2e keys from a backup and the user's recovery key */ export default class RestoreKeyBackupDialog extends React.PureComponent { + static propTypes = { + // if false, will close the dialog as soon as the restore completes succesfully + // default: true + showSummary: PropTypes.bool, + }; + + defaultProps = { + showSummary: true, + }; + constructor(props) { super(props); this.state = { @@ -96,6 +107,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( this.state.passPhrase, undefined, undefined, this.state.backupInfo, ); + if (!this.props.showSummary) { + this.props.onFinished(true); + return; + } this.setState({ loading: false, recoverInfo, @@ -119,6 +134,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( this.state.recoveryKey, undefined, undefined, this.state.backupInfo, ); + if (!this.props.showSummary) { + this.props.onFinished(true); + return; + } this.setState({ loading: false, recoverInfo, @@ -253,6 +272,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { title = _t("Error"); content = _t("No backup found!"); } else if (this.state.recoverInfo) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); title = _t("Backup Restored"); let failedToDecrypt; if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { @@ -264,6 +284,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { content =

{_t("Restored %(sessionCount)s session keys", {sessionCount: this.state.recoverInfo.imported})}

{failedToDecrypt} +
; } else if (backupHasPassphrase && !this.state.forceRecoveryKey) { const DialogButtons = sdk.getComponent('views.elements.DialogButtons');