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 a09f895d5f..083071ef6c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -33,17 +33,21 @@ @import "./views/dialogs/_ChatInviteDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; +@import "./views/dialogs/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; +@import "./views/dialogs/_RestoreKeyBackupDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss"; @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"; @@ -107,6 +111,7 @@ @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_IntegrationsManager.scss"; +@import "./views/settings/_KeyBackupPanel.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_IncomingCallbox.scss"; diff --git a/res/css/views/dialogs/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/_CreateKeyBackupDialog.scss new file mode 100644 index 0000000000..a422cf858c --- /dev/null +++ b/res/css/views/dialogs/_CreateKeyBackupDialog.scss @@ -0,0 +1,25 @@ +/* +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 { + padding-right: 40px; +} + +.mx_CreateKeyBackupDialog_recoveryKey { + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; +} diff --git a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/_RestoreKeyBackupDialog.scss new file mode 100644 index 0000000000..69e00c416a --- /dev/null +++ b/res/css/views/dialogs/_RestoreKeyBackupDialog.scss @@ -0,0 +1,19 @@ +/* +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_keyStatus { + height: 30px; +} 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/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_KeyBackupPanel.scss new file mode 100644 index 0000000000..1bcc0ab10d --- /dev/null +++ b/res/css/views/settings/_KeyBackupPanel.scss @@ -0,0 +1,32 @@ +/* +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_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_sigInvalid, +.mx_KeyBackupPanel_deviceVerified, .mx_KeyBackupPanel_deviceNotVerified { + font-weight: bold; +} + +.mx_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_deviceVerified { + color: $e2e-verified-color; +} + +.mx_KeyBackupPanel_sigInvalid, .mx_KeyBackupPanel_deviceNotVerified { + color: $e2e-warning-color; +} + +.mx_KeyBackupPanel_deviceName { + font-style: italic; +} diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index f3f188df81..4d7c71e3ef 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1375,6 +1375,7 @@ export default React.createClass({ cli.on("crypto.roomKeyRequestCancellation", (req) => { krh.handleKeyRequestCancellation(req); }); + cli.on("Room", (room) => { if (MatrixClientPeg.get().isCryptoEnabled()) { const blacklistEnabled = SettingsStore.getValueAt( diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index c02e482746..4c15b4ec27 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -737,6 +737,16 @@ module.exports = React.createClass({ ); } + + let keyBackupSection; + if (SettingsStore.isFeatureEnabled("feature_keybackup")) { + const KeyBackupPanel = sdk.getComponent('views.settings.KeyBackupPanel'); + keyBackupSection =
+

{ _t("Key Backup") }

+ +
; + } + return (

{ _t("Cryptography") }

@@ -752,6 +762,7 @@ module.exports = React.createClass({
{ CRYPTO_SETTINGS.map( this._renderDeviceSetting ) }
+ {keyBackupSection}
); }, diff --git a/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js new file mode 100644 index 0000000000..046f1d703e --- /dev/null +++ b/src/components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -0,0 +1,460 @@ +/* +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. +*/ + +import React from 'react'; +import sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; + +import FileSaver from 'file-saver'; + +import { _t, _td } from '../../../../languageHandler'; + +const PHASE_PASSPHRASE = 0; +const PHASE_PASSPHRASE_CONFIRM = 1; +const PHASE_SHOWKEY = 2; +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) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Walks the user through the process of creating an e2e key backup + * on the server. + */ +export default React.createClass({ + getInitialState: function() { + return { + phase: PHASE_PASSPHRASE, + passPhrase: '', + passPhraseConfirm: '', + copied: false, + downloaded: false, + }; + }, + + componentWillMount: function() { + this._recoveryKeyNode = null; + this._keyBackupInfo = null; + }, + + _collectRecoveryKeyNode: function(n) { + this._recoveryKeyNode = n; + }, + + _onCopyClick: function() { + selectText(this._recoveryKeyNode); + const successful = document.execCommand('copy'); + if (successful) { + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); + } + }, + + _onDownloadClick: function() { + const blob = new Blob([this._keyBackupInfo.recovery_key], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'recovery-key.txt'); + + this.setState({ + downloaded: true, + phase: PHASE_KEEPITSAFE, + }); + }, + + _createBackup: function() { + this.setState({ + phase: PHASE_BACKINGUP, + error: null, + }); + this._createBackupPromise = MatrixClientPeg.get().createKeyBackupVersion( + this._keyBackupInfo, + ).then((info) => { + return MatrixClientPeg.get().backupAllGroupSessions(info.version); + }).then(() => { + this.setState({ + phase: PHASE_DONE, + }); + }).catch(e => { + console.log("Error creating key backup", e); + this.setState({ + error: e, + }); + }); + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + _onDone: function() { + this.props.onFinished(true); + }, + + _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({ + copied: false, + phase: PHASE_SHOWKEY, + }); + }, + + _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, + }); + }, + + _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
+

{_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("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")}
+
+ + { + // FIXME REDESIGN: buttons should be adjacent but insufficient room in current design + } +

+ +
+
+ {this._keyBackupInfo.recovery_key} +
+

+
+ +
; + }, + + _renderPhaseKeepItSafe: function() { + let introText; + if (this.state.copied) { + introText = _t( + "Your Recovery Key has been copied to your clipboard, paste it to:", + {}, {b: s => {s}}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your Recovery Key is in your Downloads folder.", + {}, {b: s => {s}}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {introText} + + +
; + }, + + _renderBusyPhase: function(text) { + const Spinner = sdk.getComponent('views.elements.Spinner'); + return
+

{_t(text)}

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

{_t("Backup created")}

+

{_t("Your encryption keys are now being backed up to your Homeserver.")}

+ +
; + }, + + _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'); + + let content; + if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + content =
+

{_t("Unable to create key backup")}

+
+ +
+
; + } 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_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 new file mode 100644 index 0000000000..0cce968d34 --- /dev/null +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -0,0 +1,303 @@ +/* +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. +*/ + +import React from 'react'; +import sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +import Modal from '../../../../Modal'; + +import { _t } from '../../../../languageHandler'; + +/** + * Dialog for restoring e2e keys from a backup and the user's recovery key + */ +export default React.createClass({ + getInitialState: function() { + return { + backupInfo: null, + loading: false, + loadError: null, + restoreError: null, + recoveryKey: "", + recoverInfo: null, + recoveryKeyValid: false, + forceRecoveryKey: false, + passPhrase: '', + }; + }, + + componentWillMount: function() { + this._loadBackupStatus(); + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + _onDone: function() { + 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, + recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), + }); + }, + + _onPassPhraseNext: async function() { + this.setState({ + loading: true, + restoreError: null, + }); + try { + 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({ + loading: false, + recoverInfo, + }); + } catch (e) { + console.log("Error restoring backup", e); + this.setState({ + loading: false, + restoreError: e, + }); + } + }, + + _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, + loadError: null, + }); + try { + const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + this.setState({ + loadError: null, + loading: false, + backupInfo, + }); + } catch (e) { + console.log("Error loading backup status", e); + this.setState({ + loadError: e, + loading: false, + }); + } + }, + + render: function() { + 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( + "Failed to decrypt %(failedCount)s sessions!", + {failedCount: this.state.recoverInfo.total - this.state.recoverInfo.imported}, + )}

; + } + 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'); + 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) { + keyStatus =
; + } else if (this.state.recoveryKeyValid) { + keyStatus =
+ {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} +
; + } else { + keyStatus =
+ {"\uD83D\uDC4E "}{_t("Not a valid recovery key")} +
; + } + + content =
+ {_t( + "Access your secure message history and set up secure " + + "messaging by entering your recovery key.", + )}
+ +
+ + {keyStatus} + +
+ {_t( + "If you've forgotten your recovery passphrase you can "+ + "" + , {}, { + button: s => + {s} + , + })} +
; + } + + return ( + +
+ {content} +
+
+ ); + }, +}); diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index f314588caa..70355b56b7 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -43,7 +43,11 @@ module.exports = React.createClass({ focus: PropTypes.bool, + // disables the primary and cancel buttons disabled: PropTypes.bool, + + // disables only the primary button + primaryDisabled: PropTypes.bool, }, getDefaultProps: function() { @@ -73,9 +77,9 @@ module.exports = React.createClass({ { cancelButton } { this.props.children } diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js new file mode 100644 index 0000000000..83e78787e5 --- /dev/null +++ b/src/components/views/settings/KeyBackupPanel.js @@ -0,0 +1,240 @@ +/* +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. +*/ + +import React from 'react'; + +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; + +export default class KeyBackupPanel extends React.Component { + constructor(props) { + super(props); + + this._startNewBackup = this._startNewBackup.bind(this); + this._deleteBackup = this._deleteBackup.bind(this); + this._verifyDevice = this._verifyDevice.bind(this); + this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this); + this._restoreBackup = this._restoreBackup.bind(this); + + this._unmounted = false; + this.state = { + loading: true, + error: null, + backupInfo: null, + }; + } + + componentWillMount() { + this._loadBackupStatus(); + + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatus); + } + + componentWillUnmount() { + this._unmounted = true; + + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatus); + } + } + + _onKeyBackupStatus() { + this._loadBackupStatus(); + } + + async _loadBackupStatus() { + this.setState({loading: true}); + try { + const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + if (this._unmounted) return; + this.setState({ + backupInfo, + backupSigStatus, + loading: false, + }); + } catch (e) { + console.log("Unable to fetch key backup status", e); + if (this._unmounted) return; + this.setState({ + error: e, + loading: false, + }); + return; + } + } + + _startNewBackup() { + const CreateKeyBackupDialog = sdk.getComponent('dialogs.keybackup.CreateKeyBackupDialog'); + Modal.createTrackedDialog('Key Backup', 'Key Backup', CreateKeyBackupDialog, { + onFinished: () => { + this._loadBackupStatus(); + }, + }); + } + + _deleteBackup() { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Delete Backup', '', QuestionDialog, { + title: _t('Delete Backup'), + description: _t( + "Delete your backed up encryption keys from the server? " + + "You will no longer be able to use your recovery key to read encrypted message history", + ), + button: _t('Delete backup'), + danger: true, + onFinished: (proceed) => { + if (!proceed) return; + this.setState({loading: true}); + MatrixClientPeg.get().deleteKeyBackupVersion(this.state.backupInfo.version).then(() => { + this._loadBackupStatus(); + }); + }, + }); + } + + _restoreBackup() { + const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); + Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { + }); + } + + _verifyDevice(e) { + const device = this.state.backupSigStatus.sigs[e.target.getAttribute('data-sigindex')].device; + + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { + userId: MatrixClientPeg.get().credentials.userId, + device: device, + onFinished: () => { + this._loadBackupStatus(); + }, + }); + } + + render() { + const Spinner = sdk.getComponent("elements.Spinner"); + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + + if (this.state.error) { + return ( +
+ {_t("Unable to load key backup status")} +
+ ); + } else if (this.state.loading) { + return ; + } else if (this.state.backupInfo) { + let clientBackupStatus; + if (MatrixClientPeg.get().getKeyBackupEnabled()) { + clientBackupStatus = _t("This device is uploading keys to this backup"); + } else { + // XXX: display why and how to fix it + clientBackupStatus = _t( + "This device is not uploading keys to this backup", {}, + {b: x => {x}}, + ); + } + + let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => { + const sigStatusSubstitutions = { + validity: sub => + + {sub} + , + verify: sub => + + {sub} + , + device: sub => {sig.device.getDisplayName()}, + }; + let sigStatus; + if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) { + sigStatus = _t( + "Backup has a valid signature from this device", + {}, sigStatusSubstitutions, + ); + } else if (sig.valid && sig.device.isVerified()) { + sigStatus = _t( + "Backup has a valid signature from " + + "verified device x", + {}, sigStatusSubstitutions, + ); + } else if (sig.valid && !sig.device.isVerified()) { + sigStatus = _t( + "Backup has a valid signature from " + + "unverified device ", + {}, sigStatusSubstitutions, + ); + } else if (!sig.valid && sig.device.isVerified()) { + sigStatus = _t( + "Backup has an invalid signature from " + + "verified device ", + {}, sigStatusSubstitutions, + ); + } else if (!sig.valid && !sig.device.isVerified()) { + sigStatus = _t( + "Backup has an invalid signature from " + + "unverified device ", + {}, sigStatusSubstitutions, + ); + } + + let verifyButton; + if (!sig.device.isVerified()) { + verifyButton =

+ { _t("Verify...") } +
; + } + + return
+ {sigStatus} + {verifyButton} +
; + }); + if (this.state.backupSigStatus.sigs.length === 0) { + backupSigStatuses = _t("Backup is not signed by any of your devices"); + } + + return
+ {_t("Backup version: ")}{this.state.backupInfo.version}
+ {_t("Algorithm: ")}{this.state.backupInfo.algorithm}
+ {clientBackupStatus}
+
{backupSigStatuses}

+
+ + { _t("Restore backup") } +     + + { _t("Delete backup") } + +
; + } else { + return
+ {_t("No backup is present")}

+ + { _t("Start a new backup") } + +
; + } + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 01c4926467..557ef62edf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -225,6 +225,7 @@ "Failed to join room": "Failed to join room", "Message Pinning": "Message Pinning", "Increase performance by only loading room members on first view": "Increase performance by only loading room members on first view", + "Backup of encryption keys to server": "Backup of encryption keys to server", "Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", "Hide removed messages": "Hide removed messages", @@ -309,6 +310,24 @@ "Failed to set display name": "Failed to set display name", "Disable Notifications": "Disable Notifications", "Enable Notifications": "Enable Notifications", + "Delete Backup": "Delete Backup", + "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history": "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history", + "Delete backup": "Delete backup", + "Unable to load key backup status": "Unable to load key backup status", + "This device is uploading keys to this backup": "This device is uploading keys to this backup", + "This device is not uploading keys to this backup": "This device is not uploading keys to this backup", + "Backup has a valid signature from this device": "Backup has a valid signature from this device", + "Backup has a valid signature from verified device x": "Backup has a valid signature from verified device x", + "Backup has a valid signature from unverified device ": "Backup has a valid signature from unverified device ", + "Backup has an invalid signature from verified device ": "Backup has an invalid signature from verified device ", + "Backup has an invalid signature from unverified device ": "Backup has an invalid signature from unverified device ", + "Verify...": "Verify...", + "Backup is not signed by any of your devices": "Backup is not signed by any of your devices", + "Backup version: ": "Backup version: ", + "Algorithm: ": "Algorithm: ", + "Restore backup": "Restore backup", + "No backup is present": "No backup is present", + "Start a new backup": "Start a new backup", "Error saving email notification preferences": "Error saving email notification preferences", "An error occurred whilst saving your email notification preferences.": "An error occurred whilst saving your email notification preferences.", "Keywords": "Keywords", @@ -740,7 +759,6 @@ "Unblacklist": "Unblacklist", "Blacklist": "Blacklist", "Unverify": "Unverify", - "Verify...": "Verify...", "No results": "No results", "Delete": "Delete", "Communities": "Communities", @@ -955,6 +973,55 @@ "Room contains unknown devices": "Room contains unknown devices", "\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.", "Unknown devices": "Unknown devices", + "Secure your encrypted message history with a Recovery Passphrase.": "Secure your encrypted message history with a Recovery Passphrase.", + "You'll need it if you log out or lose access to this device.": "You'll need it if you log out or lose access to this device.", + "Enter a passphrase...": "Enter a passphrase...", + "Next": "Next", + "If you don't want encrypted message history to be availble on other devices, .": "If you don't want encrypted message history to be availble on other devices, .", + "Or, if you don't want to create a Recovery Passphrase, skip this step and .": "Or, if you don't want to create a Recovery Passphrase, skip this step and .", + "That matches!": "That matches!", + "That doesn't match.": "That doesn't match.", + "Go back to set it again.": "Go back to set it again.", + "Type in your Recovery Passphrase to confirm you remember it. If it helps, add it to your password manager or store it somewhere safe.": "Type in your Recovery Passphrase to confirm you remember it. If it helps, add it to your password manager or store it somewhere safe.", + "Repeat your passphrase...": "Repeat your passphrase...", + "Make a copy of this Recovery Key and keep it safe.": "Make a copy of this Recovery Key and keep it safe.", + "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.", + "Your Recovery Key": "Your Recovery Key", + "Copy to clipboard": "Copy to clipboard", + "Download": "Download", + "I've made a copy": "I've made a copy", + "Your Recovery Key has been copied to your clipboard, paste it to:": "Your Recovery Key has been copied to your clipboard, paste it to:", + "Your Recovery Key is in your Downloads folder.": "Your Recovery Key is in your Downloads folder.", + "Print it and store it somewhere safe": "Print it and store it somewhere safe", + "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", + "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", + "Got it": "Got it", + "Backup created": "Backup created", + "Your encryption keys are now being backed up to your Homeserver.": "Your encryption keys are now being backed up to your Homeserver.", + "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", + "Create a Recovery Passphrase": "Create a Recovery Passphrase", + "Confirm Recovery Passphrase": "Confirm Recovery Passphrase", + "Recovery Key": "Recovery Key", + "Keep it safe": "Keep it safe", + "Backing up...": "Backing up...", + "Create Key Backup": "Create Key Backup", + "Unable to create key backup": "Unable to create key backup", + "Retry": "Retry", + "Unable to load backup status": "Unable to load backup status", + "Unable to restore backup": "Unable to restore backup", + "No backup found!": "No backup found!", + "Backup Restored": "Backup Restored", + "Failed to decrypt %(failedCount)s sessions!": "Failed to decrypt %(failedCount)s sessions!", + "Restored %(sessionCount)s session keys": "Restored %(sessionCount)s session keys", + "Enter Recovery Passphrase": "Enter Recovery Passphrase", + "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 use your recovery key or set up new recovery options": "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options", + "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", + "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 ": "If you've forgotten your recovery passphrase you can ", "Private Chat": "Private Chat", "Public Chat": "Public Chat", "Custom": "Custom", @@ -1151,6 +1218,7 @@ "Autocomplete Delay (ms):": "Autocomplete Delay (ms):", "": "", "Import E2E room keys": "Import E2E room keys", + "Key Backup": "Key Backup", "Cryptography": "Cryptography", "Device ID:": "Device ID:", "Device key:": "Device key:", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index d65303b7c6..eb702a729c 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -90,6 +90,12 @@ export const SETTINGS = { controller: new LazyLoadingController(), default: true, }, + "feature_keybackup": { + isFeature: true, + displayName: _td("Backup of encryption keys to server"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "MessageComposerInput.dontSuggestEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Disable Emoji suggestions while typing'),