From 9f1c2cd3e15065640677af8a6098f7e0a561cb91 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" <jryans@gmail.com> Date: Thu, 5 Dec 2019 15:05:28 +0000 Subject: [PATCH] Add dialogs for creating and accessing secret storage This adds dialogs for creating and accessing secret storage via a passphrase or recovery key. These flows are adapted from the ones used for key backup. --- res/css/_components.scss | 2 + .../_AccessSecretStorageDialog.scss | 34 ++ .../_CreateSecretStorageDialog.scss | 88 +++ src/MatrixClientPeg.js | 40 +- .../keybackup/CreateKeyBackupDialog.js | 8 +- .../CreateSecretStorageDialog.js | 564 ++++++++++++++++++ .../keybackup/RestoreKeyBackupDialog.js | 8 +- .../AccessSecretStorageDialog.js | 224 +++++++ .../views/settings/CrossSigningPanel.js | 63 +- src/i18n/strings/en_EN.json | 49 +- 10 files changed, 1034 insertions(+), 46 deletions(-) create mode 100644 res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss create mode 100644 res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss create mode 100644 src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js create mode 100644 src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 9796b59213..b1fbe30f13 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -81,6 +81,8 @@ @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; +@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss new file mode 100644 index 0000000000..db11e91bdb --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -0,0 +1,34 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 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. +*/ + +.mx_AccessSecretStorageDialog_keyStatus { + height: 30px; +} + +.mx_AccessSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_AccessSecretStorageDialog_passPhraseInput, +.mx_AccessSecretStorageDialog_recoveryKeyInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss new file mode 100644 index 0000000000..757d8028f0 --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 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. +*/ + +.mx_CreateSecretStorageDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_CreateSecretStorageDialog_primaryContainer::after { + content: ""; + clear: both; + display: block; +} + +.mx_CreateSecretStorageDialog_passPhraseContainer { + display: flex; + align-items: start; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp { + flex: 1; + height: 85px; + margin-left: 20px; + font-size: 80%; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp progress { + width: 100%; +} + +.mx_CreateSecretStorageDialog_passPhraseInput { + flex: none; + width: 250px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_passPhraseMatch { + margin-left: 20px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_recoveryKeyContainer { + display: flex; +} + +.mx_CreateSecretStorageDialog_recoveryKey { + width: 262px; + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; + margin-right: 12px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; + display: flex; + align-items: center; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; + white-space: nowrap; +} diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index a65ebbb763..d73931f57b 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -30,6 +30,8 @@ import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; +import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; interface MatrixClientCreds { homeserverUrl: string, @@ -224,13 +226,41 @@ class MatrixClientPeg { // This stores the cross-signing private keys in memory for the JS SDK. They // are also persisted to Secure Secret Storage in account data by // the JS SDK when created. - // XXX: On desktop platforms, we plan to store only the SSSS default - // key in a secure enclave, while the cross-signing private keys - // will still be retrieved from SSSS, so it's unclear that we - // actually need these cross-signing application callbacks for Riot. - // Should the JS SDK default to in-memory storage of these itself? const keys = {}; opts.cryptoCallbacks = { + // XXX: This flow should maybe be reworked to allow retries in + // case of typos, etc. + getSecretStorageKey: async keyInfos => { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [name, info] = keyInfoEntries[0]; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, { + keyInfo: info, + }, + ); + const [input] = await finished; + if (!input) { + throw new Error("Secret storage access canceled"); + } + let key; + const { passphrase } = info; + if (passphrase) { + key = await deriveKey(input, passphrase.salt, passphrase.iterations); + } else { + key = decodeRecoveryKey(input); + } + return [name, key]; + }, + // XXX: On desktop platforms, we plan to store only the SSSS default + // key in a secure enclave, while the cross-signing private keys + // will still be retrieved from SSSS, so it's unclear that we + // actually need these cross-signing application callbacks for Riot. + // Should the JS SDK default to in-memory storage of these itself? getCrossSigningKey: k => keys[k], saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), }; diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index ba75032ea4..eae102196f 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -268,7 +268,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return <div> <p>{_t( - "<b>Warning</b>: you should only set up key backup from a trusted computer.", {}, + "<b>Warning</b>: You should only set up key backup from a trusted computer.", {}, { b: sub => <b>{sub}</b> }, )}</p> <p>{_t( @@ -382,7 +382,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { "access to your encrypted messages if you forget your passphrase.", )}</p> <p>{_t( - "Keep your recovery key somewhere very secure, like a password manager (or a safe)", + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", )}</p> <p>{bodyText}</p> <div className="mx_CreateKeyBackupDialog_primaryContainer"> @@ -410,12 +410,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let introText; if (this.state.copied) { introText = _t( - "Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:", + "Your recovery key has been <b>copied to your clipboard</b>, paste it to:", {}, {b: s => <b>{s}</b>}, ); } else if (this.state.downloaded) { introText = _t( - "Your Recovery Key is in your <b>Downloads</b> folder.", + "Your recovery key is in your <b>Downloads</b> folder.", {}, {b: s => <b>{s}</b>}, ); } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js new file mode 100644 index 0000000000..78ff2a1698 --- /dev/null +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -0,0 +1,564 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019 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 sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +import { scorePassword } from '../../../../utils/PasswordScorer'; +import FileSaver from 'file-saver'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; + +const PHASE_PASSPHRASE = 0; +const PHASE_PASSPHRASE_CONFIRM = 1; +const PHASE_SHOWKEY = 2; +const PHASE_KEEPITSAFE = 3; +const PHASE_STORING = 4; +const PHASE_DONE = 5; +const PHASE_OPTOUT_CONFIRM = 6; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. + +// XXX: copied from ShareDialog: factor out into utils +function selectText(target) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/* + * Walks the user through the process of creating a passphrase to guard Secure + * Secret Storage in account data. + */ +export default class CreateSecretStorageDialog extends React.PureComponent { + constructor(props) { + super(props); + + this._keyInfo = null; + this._encodedRecoveryKey = null; + this._recoveryKeyNode = null; + this._setZxcvbnResultTimeout = null; + + this.state = { + phase: PHASE_PASSPHRASE, + passPhrase: '', + passPhraseConfirm: '', + copied: false, + downloaded: false, + zxcvbnResult: null, + setPassPhrase: false, + }; + } + + componentWillUnmount() { + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + } + + _collectRecoveryKeyNode = (n) => { + this._recoveryKeyNode = n; + } + + _onCopyClick = () => { + selectText(this._recoveryKeyNode); + const successful = document.execCommand('copy'); + if (successful) { + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); + } + } + + _onDownloadClick = () => { + const blob = new Blob([this._encodedRecoveryKey], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'recovery-key.txt'); + + this.setState({ + downloaded: true, + phase: PHASE_KEEPITSAFE, + }); + } + + _bootstrapSecretStorage = async () => { + this.setState({ + phase: PHASE_STORING, + error: null, + }); + const cli = MatrixClientPeg.get(); + try { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + createSecretStorageKey: async () => this._keyInfo, + }); + this.setState({ + phase: PHASE_DONE, + }); + } catch (e) { + this.setState({ error: e }); + console.error("Error bootstrapping secret storage", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + _onDone = () => { + this.props.onFinished(true); + } + + _onOptOutClick = () => { + this.setState({phase: PHASE_OPTOUT_CONFIRM}); + } + + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = () => { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + } + + _onPassPhraseKeyPress = async (e) => { + if (e.key === 'Enter') { + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); + } + if (this._passPhraseIsValid()) { + this._onPassPhraseNextClick(); + } + } + } + + _onPassPhraseConfirmNextClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + setPassPhrase: true, + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseConfirmKeyPress = (e) => { + if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { + this._onPassPhraseConfirmNextClick(); + } + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + zxcvbnResult: null, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + this._setZxcvbnResultTimeout = setTimeout(() => { + this._setZxcvbnResultTimeout = null; + this.setState({ + // precompute this and keep it in state: zxcvbn is fast but + // we use it in a couple of different places so no point recomputing + // it unnecessarily. + zxcvbnResult: scorePassword(this.state.passPhrase), + }); + }, PASSPHRASE_FEEDBACK_DELAY); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); + } + + _passPhraseIsValid() { + return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; + } + + _renderPhasePassPhrase() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let strengthMeter; + let helpText; + if (this.state.zxcvbnResult) { + if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { + helpText = _t("Great! This passphrase looks strong enough."); + } else { + const suggestions = []; + for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) { + suggestions.push(<div key={i}>{this.state.zxcvbnResult.feedback.suggestions[i]}</div>); + } + const suggestionBlock = <div>{suggestions.length > 0 ? suggestions : _t("Keep going...")}</div>; + + helpText = <div> + {this.state.zxcvbnResult.feedback.warning} + {suggestionBlock} + </div>; + } + strengthMeter = <div> + <progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} /> + </div>; + } + + return <div> + <p>{_t( + "<b>Warning</b>: You should only set up secret storage from a trusted computer.", {}, + { b: sub => <b>{sub}</b> }, + )}</p> + <p>{_t( + "We'll use secret storage to optionally store an encrypted copy of " + + "your cross-signing identity for verifying other devices and message " + + "keys on our server. Protect your access to encrypted messages with a " + + "passphrase to keep it secure.", + )}</p> + <p>{_t("For maximum security, this should be different from your account password.")}</p> + + <div className="mx_CreateSecretStorageDialog_primaryContainer"> + <div className="mx_CreateSecretStorageDialog_passPhraseContainer"> + <input type="password" + onChange={this._onPassPhraseChange} + onKeyPress={this._onPassPhraseKeyPress} + value={this.state.passPhrase} + className="mx_CreateSecretStorageDialog_passPhraseInput" + placeholder={_t("Enter a passphrase...")} + autoFocus={true} + /> + <div className="mx_CreateSecretStorageDialog_passPhraseHelp"> + {strengthMeter} + {helpText} + </div> + </div> + </div> + + <DialogButtons primaryButton={_t('Next')} + onPrimaryButtonClick={this._onPassPhraseNextClick} + hasCancel={false} + disabled={!this._passPhraseIsValid()} + /> + + <details> + <summary>{_t("Advanced")}</summary> + <p><button onClick={this._onSkipPassPhraseClick} > + {_t("Set up with a recovery key")} + </button></p> + </details> + </div>; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } 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."); + } + + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch = <div className="mx_CreateSecretStorageDialog_passPhraseMatch"> + <div>{matchText}</div> + <div> + <AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}> + {_t("Go back to set it again.")} + </AccessibleButton> + </div> + </div>; + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return <div> + <p>{_t( + "Please enter your passphrase a second time to confirm.", + )}</p> + <div className="mx_CreateSecretStorageDialog_primaryContainer"> + <div className="mx_CreateSecretStorageDialog_passPhraseContainer"> + <div> + <input type="password" + onChange={this._onPassPhraseConfirmChange} + onKeyPress={this._onPassPhraseConfirmKeyPress} + value={this.state.passPhraseConfirm} + className="mx_CreateSecretStorageDialog_passPhraseInput" + placeholder={_t("Repeat your passphrase...")} + autoFocus={true} + /> + </div> + {passPhraseMatch} + </div> + </div> + <DialogButtons primaryButton={_t('Next')} + onPrimaryButtonClick={this._onPassPhraseConfirmNextClick} + hasCancel={false} + disabled={this.state.passPhrase !== this.state.passPhraseConfirm} + /> + </div>; + } + + _renderPhaseShowKey() { + let bodyText; + if (this.state.setPassPhrase) { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages if you forget your passphrase.", + ); + } else { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages.", + ); + } + + return <div> + <p>{_t( + "Your recovery key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your passphrase.", + )}</p> + <p>{_t( + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", + )}</p> + <p>{bodyText}</p> + <div className="mx_CreateSecretStorageDialog_primaryContainer"> + <div className="mx_CreateSecretStorageDialog_recoveryKeyHeader"> + {_t("Your Recovery Key")} + </div> + <div className="mx_CreateSecretStorageDialog_recoveryKeyContainer"> + <div className="mx_CreateSecretStorageDialog_recoveryKey"> + <code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code> + </div> + <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons"> + <button className="mx_Dialog_primary" onClick={this._onCopyClick}> + {_t("Copy to clipboard")} + </button> + <button className="mx_Dialog_primary" onClick={this._onDownloadClick}> + {_t("Download")} + </button> + </div> + </div> + </div> + </div>; + } + + _renderPhaseKeepItSafe() { + let introText; + if (this.state.copied) { + introText = _t( + "Your recovery key has been <b>copied to your clipboard</b>, paste it to:", + {}, {b: s => <b>{s}</b>}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your recovery key is in your <b>Downloads</b> folder.", + {}, {b: s => <b>{s}</b>}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return <div> + {introText} + <ul> + <li>{_t("<b>Print it</b> and store it somewhere safe", {}, {b: s => <b>{s}</b>})}</li> + <li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li> + <li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li> + </ul> + <DialogButtons primaryButton={_t("OK")} + onPrimaryButtonClick={this._bootstrapSecretStorage} + hasCancel={false}> + <button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button> + </DialogButtons> + </div>; + } + + _renderBusyPhase(text) { + const Spinner = sdk.getComponent('views.elements.Spinner'); + return <div> + <Spinner /> + </div>; + } + + _renderPhaseDone() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return <div> + <p>{_t( + "Your access to encrypted messages is now protected.", + )}</p> + <DialogButtons primaryButton={_t('OK')} + onPrimaryButtonClick={this._onDone} + hasCancel={false} + /> + </div>; + } + + _renderPhaseOptOutConfirm() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return <div> + {_t( + "Without setting up secret storage, you won't be able to restore your " + + "access to encrypted messages or your cross-signing identity for " + + "verifying other devices if you log out or use another device.", + )} + <DialogButtons primaryButton={_t('Set up secret storage')} + onPrimaryButtonClick={this._onSetUpClick} + hasCancel={false} + > + <button onClick={this._onCancel}>I understand, continue without</button> + </DialogButtons> + </div>; + } + + _titleForPhase(phase) { + switch (phase) { + case PHASE_PASSPHRASE: + return _t('Secure your encrypted messages with a passphrase'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm your passphrase'); + case PHASE_OPTOUT_CONFIRM: + return _t('Warning!'); + case PHASE_SHOWKEY: + return _t('Recovery key'); + case PHASE_KEEPITSAFE: + return _t('Keep it safe'); + case PHASE_STORING: + return _t('Storing secrets...'); + case PHASE_DONE: + return _t('Success!'); + default: + return null; + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + let content; + if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + content = <div> + <p>{_t("Unable to set up secret storage")}</p> + <div className="mx_Dialog_buttons"> + <DialogButtons primaryButton={_t('Retry')} + onPrimaryButtonClick={this._bootstrapSecretStorage} + hasCancel={true} + onCancel={this._onCancel} + /> + </div> + </div>; + } else { + switch (this.state.phase) { + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); + break; + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); + break; + case PHASE_SHOWKEY: + content = this._renderPhaseShowKey(); + break; + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); + break; + case PHASE_STORING: + content = this._renderBusyPhase(); + break; + case PHASE_DONE: + content = this._renderPhaseDone(); + break; + case PHASE_OPTOUT_CONFIRM: + content = this._renderPhaseOptOutConfirm(); + break; + } + } + + return ( + <BaseDialog className='mx_CreateSecretStorageDialog' + onFinished={this.props.onFinished} + title={this._titleForPhase(this.state.phase)} + hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)} + > + <div> + {content} + </div> + </BaseDialog> + ); + } +} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 9fcb663af9..45168c381e 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -27,7 +27,7 @@ import {Key} from "../../../../Keyboard"; const RESTORE_TYPE_PASSPHRASE = 0; const RESTORE_TYPE_RECOVERYKEY = 1; -/** +/* * Dialog for restoring e2e keys from a backup and the user's recovery key */ export default class RestoreKeyBackupDialog extends React.PureComponent { @@ -47,7 +47,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { }; } - componentWillMount() { + componentDidMount() { this._loadBackupStatus(); } @@ -296,7 +296,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { content = <div> <p>{_t( - "<b>Warning</b>: you should only set up key backup " + + "<b>Warning</b>: You should only set up key backup " + "from a trusted computer.", {}, { b: sub => <b>{sub}</b> }, )}</p> @@ -322,7 +322,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { /> </div> {_t( - "If you've forgotten your recovery passphrase you can "+ + "If you've forgotten your recovery key you can "+ "<button>set up new recovery options</button>" , {}, { button: s => <AccessibleButton className="mx_linkButton" diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js new file mode 100644 index 0000000000..8db56e6dfb --- /dev/null +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -0,0 +1,224 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019 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 sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; + +import { _t } from '../../../../languageHandler'; +import {Key} from "../../../../Keyboard"; + +/* + * Access Secure Secret Storage by requesting the user's passphrase. + */ +export default class AccessSecretStorageDialog extends React.PureComponent { + static propTypes = { + // { passphrase, pubkey } + keyInfo: PropTypes.object.isRequired, + } + + constructor(props) { + super(props); + this.state = { + recoveryKey: "", + recoveryKeyValid: false, + forceRecoveryKey: false, + passPhrase: '', + }; + } + + _onCancel = () => { + this.props.onFinished(false); + } + + _onUseRecoveryKeyClick = () => { + this.setState({ + forceRecoveryKey: true, + }); + } + + _onResetRecoveryClick = () => { + this.props.onFinished(false); + throw new Error("Resetting secret storage unimplemented"); + } + + _onRecoveryKeyChange = (e) => { + this.setState({ + recoveryKey: e.target.value, + recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), + }); + } + + _onPassPhraseNext = async () => { + this.props.onFinished(this.state.passPhrase); + } + + _onRecoveryKeyNext = async () => { + this.props.onFinished(this.state.recoveryKey); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + } + + _onPassPhraseKeyPress = (e) => { + if (e.key === Key.ENTER) { + this._onPassPhraseNext(); + } + } + + _onRecoveryKeyKeyPress = (e) => { + if (e.key === Key.ENTER && this.state.recoveryKeyValid) { + this._onRecoveryKeyNext(); + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const hasPassphrase = ( + this.props.keyInfo && + this.props.keyInfo.passphrase && + this.props.keyInfo.passphrase.salt && + this.props.keyInfo.passphrase.iterations + ); + + let content; + let title; + if (hasPassphrase && !this.state.forceRecoveryKey) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + title = _t("Enter secret storage passphrase"); + content = <div> + <p>{_t( + "<b>Warning</b>: You should only access secret storage " + + "from a trusted computer.", {}, + { b: sub => <b>{sub}</b> }, + )}</p> + <p>{_t( + "Access your secure message history and your cross-signing " + + "identity for verifying other devices by entering your passphrase.", + )}</p> + + <div className="mx_AccessSecretStorageDialog_primaryContainer"> + <input type="password" + className="mx_AccessSecretStorageDialog_passPhraseInput" + onChange={this._onPassPhraseChange} + onKeyPress={this._onPassPhraseKeyPress} + value={this.state.passPhrase} + autoFocus={true} + /> + <DialogButtons primaryButton={_t('Next')} + onPrimaryButtonClick={this._onPassPhraseNext} + hasCancel={true} + onCancel={this._onCancel} + focus={false} + /> + </div> + {_t( + "If you've forgotten your passphrase you can "+ + "<button1>use your recovery key</button1> or " + + "<button2>set up new recovery options</button2>." + , {}, { + button1: s => <AccessibleButton className="mx_linkButton" + element="span" + onClick={this._onUseRecoveryKeyClick} + > + {s} + </AccessibleButton>, + button2: s => <AccessibleButton className="mx_linkButton" + element="span" + onClick={this._onResetRecoveryClick} + > + {s} + </AccessibleButton>, + })} + </div>; + } else { + title = _t("Enter secret storage recovery key"); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let keyStatus; + if (this.state.recoveryKey.length === 0) { + keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus"></div>; + } else if (this.state.recoveryKeyValid) { + keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus"> + {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} + </div>; + } else { + keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus"> + {"\uD83D\uDC4E "}{_t("Not a valid recovery key")} + </div>; + } + + content = <div> + <p>{_t( + "<b>Warning</b>: You should only access secret storage " + + "from a trusted computer.", {}, + { b: sub => <b>{sub}</b> }, + )}</p> + <p>{_t( + "Access your secure message history and your cross-signing " + + "identity for verifying other devices by entering your recovery key.", + )}</p> + + <div className="mx_AccessSecretStorageDialog_primaryContainer"> + <input className="mx_AccessSecretStorageDialog_recoveryKeyInput" + onChange={this._onRecoveryKeyChange} + onKeyPress={this._onRecoveryKeyKeyPress} + value={this.state.recoveryKey} + autoFocus={true} + /> + {keyStatus} + <DialogButtons primaryButton={_t('Next')} + onPrimaryButtonClick={this._onRecoveryKeyNext} + hasCancel={true} + onCancel={this._onCancel} + focus={false} + primaryDisabled={!this.state.recoveryKeyValid} + /> + </div> + {_t( + "If you've forgotten your recovery key you can "+ + "<button>set up new recovery options</button>." + , {}, { + button: s => <AccessibleButton className="mx_linkButton" + element="span" + onClick={this._onResetRecoveryClick} + > + {s} + </AccessibleButton>, + })} + </div>; + } + + return ( + <BaseDialog className='mx_AccessSecretStorageDialog' + onFinished={this.props.onFinished} + title={title} + > + <div> + {content} + </div> + </BaseDialog> + ); + } +} diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 9c7c2ea38a..fda92ebac9 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -24,6 +24,9 @@ import Modal from '../../../Modal'; export default class CrossSigningPanel extends React.PureComponent { constructor(props) { super(props); + + this._unmounted = false; + this.state = { error: null, ...this._getUpdatedStatus(), @@ -36,6 +39,7 @@ export default class CrossSigningPanel extends React.PureComponent { } componentWillUnmount() { + this._unmounted = true; const cli = MatrixClientPeg.get(); if (!cli) return; cli.removeListener("accountData", this.onAccountData); @@ -64,30 +68,53 @@ export default class CrossSigningPanel extends React.PureComponent { }; } + /** + * Bootstrapping secret storage may take one of these paths: + * 1. Create secret storage from a passphrase and store cross-signing keys + * in secret storage. + * 2. Access existing secret storage by requesting passphrase and accessing + * cross-signing keys as needed. + * 3. All keys are loaded and there's nothing to do. + */ _bootstrapSecureSecretStorage = async () => { this.setState({ error: null }); + const cli = MatrixClientPeg.get(); try { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await MatrixClientPeg.get().bootstrapSecretStorage({ - authUploadDeviceSigningKeys: async (makeRequest) => { - const { finished } = Modal.createTrackedDialog( - 'Cross-signing keys dialog', '', InteractiveAuthDialog, - { - title: _t("Send cross-signing keys to homeserver"), - matrixClient: MatrixClientPeg.get(), - makeRequest, - }, - ); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - }); + if (!cli.hasSecretStorageKey()) { + // 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"), + null, null, /* priority = */ false, /* static = */ true, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Secret storage creation canceled"); + } + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + }); + } } catch (e) { this.setState({ error: e }); - console.error(e); + console.error("Error bootstrapping secret storage", e); } + if (this._unmounted) return; this.setState(this._getUpdatedStatus()); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b60a684e05..ab26d677a3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1517,6 +1517,15 @@ "Remember my selection for this widget": "Remember my selection for this widget", "Allow": "Allow", "Deny": "Deny", + "Enter secret storage passphrase": "Enter secret storage passphrase", + "<b>Warning</b>: You should only access secret storage from a trusted computer.": "<b>Warning</b>: You should only access secret storage from a trusted computer.", + "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.", + "If you've forgotten your passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>.": "If you've forgotten your passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>.", + "Enter secret storage recovery key": "Enter secret storage recovery key", + "This looks like a valid recovery key!": "This looks like a valid recovery key!", + "Not a valid recovery key": "Not a valid recovery key", + "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.", + "If you've forgotten your recovery key you can <button>set up new recovery options</button>.": "If you've forgotten your recovery key you can <button>set up new recovery options</button>.", "Unable to load backup status": "Unable to load backup status", "Recovery Key Mismatch": "Recovery Key Mismatch", "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.": "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.", @@ -1532,10 +1541,9 @@ "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Access your secure message history and set up secure messaging by entering your recovery passphrase.", "If you've forgotten your recovery passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>": "If you've forgotten your recovery passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>", "Enter Recovery Key": "Enter Recovery Key", - "This looks like a valid recovery key!": "This looks like a valid recovery key!", - "Not a valid recovery key": "Not a valid recovery key", + "<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Warning</b>: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your recovery key.": "Access your secure message history and set up secure messaging by entering your recovery key.", - "If you've forgotten your recovery passphrase you can <button>set up new recovery options</button>": "If you've forgotten your recovery passphrase you can <button>set up new recovery options</button>", + "If you've forgotten your recovery key you can <button>set up new recovery options</button>": "If you've forgotten your recovery key you can <button>set up new recovery options</button>", "Private Chat": "Private Chat", "Public Chat": "Public Chat", "Custom": "Custom", @@ -1885,39 +1893,50 @@ "File to import": "File to import", "Import": "Import", "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", - "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", + "<b>Warning</b>: You should only set up secret storage from a trusted computer.": "<b>Warning</b>: You should only set up secret storage from a trusted computer.", + "We'll use secret storage to optionally store an encrypted copy of your cross-signing identity for verifying other devices and message keys on our server. Protect your access to encrypted messages with a passphrase to keep it secure.": "We'll use secret storage to optionally store an encrypted copy of your cross-signing identity for verifying other devices and message keys on our server. Protect your access to encrypted messages with a passphrase to keep it secure.", "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "Enter a passphrase...": "Enter a passphrase...", - "Set up with a Recovery Key": "Set up with a Recovery Key", + "Set up with a recovery key": "Set up with a recovery key", "That matches!": "That matches!", "That doesn't match.": "That doesn't match.", "Go back to set it again.": "Go back to set it again.", "Please enter your passphrase a second time to confirm.": "Please enter your passphrase a second time to confirm.", "Repeat your passphrase...": "Repeat your passphrase...", - "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.", - "As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.", + "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.": "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.", + "As a safety net, you can use it to restore your access to encrypted messages.": "As a safety net, you can use it to restore your access to encrypted messages.", "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.", - "Keep your recovery key somewhere very secure, like a password manager (or a safe)": "Keep your recovery key somewhere very secure, like a password manager (or a safe)", + "Keep your recovery key somewhere very secure, like a password manager (or a safe).": "Keep your recovery key somewhere very secure, like a password manager (or a safe).", "Your Recovery Key": "Your Recovery Key", "Copy to clipboard": "Copy to clipboard", "Download": "Download", - "Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:": "Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:", - "Your Recovery Key is in your <b>Downloads</b> folder.": "Your Recovery Key is in your <b>Downloads</b> folder.", + "Your recovery key has been <b>copied to your clipboard</b>, paste it to:": "Your recovery key has been <b>copied to your clipboard</b>, paste it to:", + "Your recovery key is in your <b>Downloads</b> folder.": "Your recovery key is in your <b>Downloads</b> folder.", "<b>Print it</b> and store it somewhere safe": "<b>Print it</b> and store it somewhere safe", "<b>Save it</b> on a USB key or backup drive": "<b>Save it</b> on a USB key or backup drive", "<b>Copy it</b> to your personal cloud storage": "<b>Copy it</b> to your personal cloud storage", + "Your access to encrypted messages is now protected.": "Your access to encrypted messages is now protected.", + "Without setting up secret storage, you won't be able to restore your access to encrypted messages or your cross-signing identity for verifying other devices if you log out or use another device.": "Without setting up secret storage, you won't be able to restore your access to encrypted messages or your cross-signing identity for verifying other devices if you log out or use another device.", + "Set up secret storage": "Set up secret storage", + "Secure your encrypted messages with a passphrase": "Secure your encrypted messages with a passphrase", + "Confirm your passphrase": "Confirm your passphrase", + "Recovery key": "Recovery key", + "Keep it safe": "Keep it safe", + "Storing secrets...": "Storing secrets...", + "Success!": "Success!", + "Unable to set up secret storage": "Unable to set up secret storage", + "Retry": "Retry", + "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", + "Set up with a Recovery Key": "Set up with a Recovery Key", + "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.", + "As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.", "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.", "Set up Secure Message Recovery": "Set up Secure Message Recovery", "Secure your backup with a passphrase": "Secure your backup with a passphrase", - "Confirm your passphrase": "Confirm your passphrase", - "Recovery key": "Recovery key", - "Keep it safe": "Keep it safe", "Starting backup...": "Starting backup...", - "Success!": "Success!", "Create Key Backup": "Create Key Backup", "Unable to create key backup": "Unable to create key backup", - "Retry": "Retry", "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.", "If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.", "Set up": "Set up",