diff --git a/res/css/_common.scss b/res/css/_common.scss index 9123e5ba8d..11e04f5dc0 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -262,6 +262,11 @@ textarea { opacity: 0.7; } +.mx_linkButton { + cursor: pointer; + color: $accent-color; +} + .mx_Dialog_title { min-height: 16px; padding-top: 40px; diff --git a/res/css/_components.scss b/res/css/_components.scss index 9fd524104d..083071ef6c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -46,6 +46,8 @@ @import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; +@import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; +@import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss new file mode 100644 index 0000000000..507c89ace7 --- /dev/null +++ b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss @@ -0,0 +1,39 @@ +/* +Copyright 2018 New Vector Ltd + +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_CreateKeyBackupDialog_primaryContainer { + /*FIXME: plinth colour in new theme(s). background-color: $accent-color;*/ + padding: 20px +} + +.mx_CreateKeyBackupDialog_passPhraseInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + +.mx_CreateKeyBackupDialog_passPhraseMatch { + float: right; +} + +.mx_CreateKeyBackupDialog_recoveryKeyButtons { + float: right; +} + +.mx_CreateKeyBackupDialog_recoveryKey { + width: 300px; +} diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss new file mode 100644 index 0000000000..612c921038 --- /dev/null +++ b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss @@ -0,0 +1,29 @@ +/* +Copyright 2018 New Vector Ltd + +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_RestoreKeyBackupDialog_primaryContainer { + /*FIXME: plinth colour in new theme(s). background-color: $accent-color;*/ + padding: 20px +} + +.mx_RestoreKeyBackupDialog_passPhraseInput, +.mx_RestoreKeyBackupDialog_recoveryKeyInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + diff --git a/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js index e561fe033c..a3ef8e7f19 100644 --- a/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -17,16 +17,16 @@ limitations under the License. import React from 'react'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; -import Promise from 'bluebird'; import { _t, _td } from '../../../../languageHandler'; -const PHASE_INTRO = 0; -const PHASE_GENERATING = 1; +const PHASE_PASSPHRASE = 0; +const PHASE_PASSPHRASE_CONFIRM = 1; const PHASE_SHOWKEY = 2; -const PHASE_MAKEBACKUP = 3; -const PHASE_UPLOAD = 4; +const PHASE_KEEPITSAFE = 3; +const PHASE_BACKINGUP = 4; const PHASE_DONE = 5; +const PHASE_OPTOUT_CONFIRM = 6; // XXX: copied from ShareDialog: factor out into utils function selectText(target) { @@ -39,13 +39,16 @@ function selectText(target) { } /** - * Walks the user through the process of creating an e22 key backup + * Walks the user through the process of creating an e2e key backup * on the server. */ export default React.createClass({ getInitialState: function() { return { - phase: PHASE_INTRO, + phase: PHASE_PASSPHRASE, + passPhrase: '', + passPhraseConfirm: '', + copied: false, }; }, @@ -58,25 +61,25 @@ export default React.createClass({ this._recoveryKeyNode = n; }, - _copyRecoveryKey: function() { + _onCopyClick: function() { selectText(this._recoveryKeyNode); const successful = document.execCommand('copy'); if (successful) { - this.setState({copied: true}); + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); } }, _createBackup: function() { this.setState({ - phase: PHASE_MAKEBACKUP, + phase: PHASE_BACKINGUP, error: null, }); this._createBackupPromise = MatrixClientPeg.get().createKeyBackupVersion( this._keyBackupInfo, ).then((info) => { - this.setState({ - phase: PHASE_UPLOAD, - }); return MatrixClientPeg.get().backupAllGroupSessions(info.version); }).then(() => { this.setState({ @@ -98,61 +101,219 @@ export default React.createClass({ this.props.onFinished(true); }, - _generateKey: async function() { + _onOptOutClick: function() { + this.setState({phase: PHASE_OPTOUT_CONFIRM}); + }, + + _onSetUpClick: function() { + this.setState({phase: PHASE_PASSPHRASE}); + }, + + _onSkipPassPhraseClick: async function() { + this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(); this.setState({ - phase: PHASE_GENERATING, + copied: false, + phase: PHASE_SHOWKEY, }); - // Look, work is being done! - await Promise.delay(1200); - this._keyBackupInfo = MatrixClientPeg.get().prepareKeyBackupVersion(); + }, + + _onPassPhraseNextClick: function() { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + }, + + _onPassPhraseKeyPress: function(e) { + if (e.key === 'Enter' && this._passPhraseIsValid()) { + this._onPassPhraseNextClick(); + } + }, + + _onPassPhraseConfirmNextClick: async function() { + this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase); + this.setState({ + copied: false, + phase: PHASE_SHOWKEY, + }); + }, + + _onPassPhraseConfirmKeyPress: function(e) { + if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { + this._onPassPhraseConfirmNextClick(); + } + }, + + _onSetAgainClick: function() { + this.setState({ + passPhrase: '', + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + }); + }, + + _onKeepItSafeGotItClick: function() { this.setState({ phase: PHASE_SHOWKEY, }); }, - _renderPhaseIntro: function() { + _onPassPhraseChange: function(e) { + this.setState({ + passPhrase: e.target.value, + }); + }, + + _onPassPhraseConfirmChange: function(e) { + this.setState({ + passPhraseConfirm: e.target.value, + }); + }, + + _passPhraseIsValid: function() { + return this.state.passPhrase !== ''; + }, + + _renderPhasePassPhrase: function() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return
+

{_t("Secure your encrypted message history with a Recovery Passphrase.")}

+

{_t("You'll need it if you log out or lose access to this device.")}

+ +
+ +
+ + + +

{_t( + "If you don't want encrypted message history to be availble on other devices, "+ + ".", + {}, + { + button: sub => + {sub} + , + }, + )}

+

{_t( + "Or, if you don't want to create a Recovery Passphrase, skip this step and "+ + ".", + {}, + { + button: sub => + {sub} + , + }, + )}

+
; + }, + + _renderPhasePassPhraseConfirm: function() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let passPhraseMatch = null; + if (this.state.passPhraseConfirm.length > 0) { + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } else { + matchText = _t("That doesn't match."); + } + passPhraseMatch =
+
{matchText}
+
+ + {_t("Go back to set it again.")} + +
+
; + } const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

To avoid ever losing your encrypted message history, you - can save your encryption keys on the server, protected by a recovery key. -

-

To maximise security, your recovery key is never stored by the app, - so you must store it yourself somewhere safe.

-

Warning: storing your encryption keys on the server means that - if someone gains access to your account and also steals your recovery key, - they will be able to read all of your encrypted conversation history. -

- -

Do you wish to generate a recovery key and backup your encryption - keys on the server?

- - {_t( + "Type in your Recovery Passphrase to confirm you remember it. " + + "If it helps, add it to your password manager or store it " + + "somewhere safe.", + )}

+
+ {passPhraseMatch} +
+ +
+
+
; }, _renderPhaseShowKey: function() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t("Here is your recovery key:")}

-

- {this._keyBackupInfo.recovery_key} +

{_t("Make a copy of this Recovery Key and keep it safe.")}

+

{_t("As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.")}

+

+

{_t("Your Recovery Key")}
+
+ +
+
+ {this._keyBackupInfo.recovery_key} +

-

{_t("This key can decrypt your full message history.")}

-

{_t( - "When you've saved it somewhere safe, proceed to the next step where the key will be used to "+ - "create an encrypted backup of your message keys and then destroyed.", - )}

-
- - -
+ +
; + }, + + _renderPhaseKeepItSafe: function() { + let introText; + if (this.state.copied) { + introText = _t("Your Recovery Key has been copied to your clipboard, paste it to:"); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {introText} + +
; }, @@ -176,6 +337,41 @@ export default React.createClass({ ; }, + _renderPhaseOptOutConfirm: function() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {_t( + "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.", + )} + + + +
; + }, + + _titleForPhase: function(phase) { + switch (phase) { + case PHASE_PASSPHRASE: + return _t('Create a Recovery Passphrase'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm Recovery 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_BACKINGUP: + return _t('Backing up...'); + default: + return _t("Create Key Backup"); + } + }, + render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -194,32 +390,35 @@ export default React.createClass({ ; } else { switch (this.state.phase) { - case PHASE_INTRO: - content = this._renderPhaseIntro(); + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); break; - case PHASE_GENERATING: - content = this._renderBusyPhase(_td("Generating recovery key...")); + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); break; case PHASE_SHOWKEY: content = this._renderPhaseShowKey(); break; - case PHASE_MAKEBACKUP: - content = this._renderBusyPhase(_td("Creating backup...")); + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); break; - case PHASE_UPLOAD: - content = this._renderBusyPhase(_td("Uploading keys...")); + case PHASE_BACKINGUP: + content = this._renderBusyPhase(_td("Backing up...")); break; case PHASE_DONE: content = this._renderPhaseDone(); break; + case PHASE_OPTOUT_CONFIRM: + content = this._renderPhaseOptOutConfirm(); + break; } } return (
{content} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 9e5e61cb1a..0cce968d34 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; +import Modal from '../../../../Modal'; import { _t } from '../../../../languageHandler'; @@ -33,6 +34,8 @@ export default React.createClass({ recoveryKey: "", recoverInfo: null, recoveryKeyValid: false, + forceRecoveryKey: false, + passPhrase: '', }; }, @@ -48,6 +51,18 @@ export default React.createClass({ this.props.onFinished(true); }, + _onUseRecoveryKeyClick: function() { + this.setState({ + forceRecoveryKey: true, + }); + }, + + _onResetRecoveryClick: function() { + this.props.onFinished(false); + const CreateKeyBackupDialog = sdk.getComponent("dialogs.keybackup.CreateKeyBackupDialog"); + Modal.createTrackedDialog('Create Key Backup', '', CreateKeyBackupDialog, {}); + }, + _onRecoveryKeyChange: function(e) { this.setState({ recoveryKey: e.target.value, @@ -55,13 +70,35 @@ export default React.createClass({ }); }, - _onRecoverClick: async function() { + _onPassPhraseNext: async function() { this.setState({ loading: true, restoreError: null, }); try { - const recoverInfo = await MatrixClientPeg.get().restoreKeyBackups( + const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword( + this.state.passPhrase, undefined, undefined, this.state.backupInfo.version, + ); + this.setState({ + loading: false, + recoverInfo, + }); + } catch (e) { + console.log("Error restoring backup", e); + this.setState({ + loading: false, + restoreError: e, + }); + } + }, + + _onRecoveryKeyNext: async function() { + this.setState({ + loading: true, + restoreError: null, + }); + try { + const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey( this.state.recoveryKey, undefined, undefined, this.state.backupInfo.version, ); this.setState({ @@ -77,6 +114,24 @@ export default React.createClass({ } }, + _onPassPhraseChange: function(e) { + this.setState({ + passPhrase: e.target.value, + }); + }, + + _onPassPhraseKeyPress: function(e) { + if (e.key === "Enter") { + this._onPassPhraseNext(); + } + }, + + _onRecoveryKeyKeyPress: function(e) { + if (e.key === "Enter" && this.state.recoveryKeyValid) { + this._onRecoveryKeyNext(); + } + }, + _loadBackupStatus: async function() { this.setState({ loading: true, @@ -102,16 +157,29 @@ export default React.createClass({ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent("elements.Spinner"); + const backupHasPassphrase = ( + this.state.backupInfo && + this.state.backupInfo.auth_data && + this.state.backupInfo.auth_data.private_key_salt && + this.state.backupInfo.auth_data.private_key_iterations + ); + let content; + let title; if (this.state.loading) { + title = _t("Loading..."); content = ; } else if (this.state.loadError) { + title = _t("Error"); content = _t("Unable to load backup status"); } else if (this.state.restoreError) { + title = _t("Error"); content = _t("Unable to restore backup"); } else if (this.state.backupInfo === null) { + title = _t("Error"); content = _t("No backup found!"); } else if (this.state.recoverInfo) { + title = _t("Backup Restored"); let failedToDecrypt; if (this.state.recoverInfo.total > this.state.recoverInfo.imported) { failedToDecrypt =

{_t( @@ -123,8 +191,54 @@ export default React.createClass({

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

{failedToDecrypt}
; - } else { + } else if (backupHasPassphrase && !this.state.forceRecoveryKey) { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + title = _t("Enter Recovery Passphrase"); + content =
+ {_t( + "Access your secure message history and set up secure " + + "messaging by entering your recovery passphrase.", + )}
+ +
+ + +
+ {_t( + "If you've forgotten your recovery passphrase you can "+ + "use your recovery key or " + + "set up new recovery options" + , {}, { + button1: s => + {s} + , + button2: s => + {s} + , + })} +
; + } else { + title = _t("Enter Recovery Key"); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let keyStatus; if (this.state.recoveryKey.length === 0) { @@ -140,28 +254,45 @@ export default React.createClass({ } content =
- {_t("Please enter the recovery key generated when you set up key backup")}
-