From c6496d02efd6c4cf003b51dd17b0c50ce649efd8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 4 Oct 2022 21:10:17 +0200 Subject: [PATCH] add recovery paths (disable and confirm/reset OTP) for 2FA --- electrum/gui/qml/qewizard.py | 2 +- electrum/gui/wizard.py | 12 +- electrum/plugins/trustedcoin/qml.py | 150 +++++++++++++----- .../plugins/trustedcoin/qml/KeepDisable.qml | 35 ++++ .../trustedcoin/qml/ShowConfirmOTP.qml | 54 +++++-- 5 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 electrum/plugins/trustedcoin/qml/KeepDisable.qml diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index 34cdbf079..1251117b9 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -73,7 +73,7 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): self._path = path self.pathChanged.emit() - def last_if_single_password(self, view, wizard_data): + def last_if_single_password(self, *args): return self._daemon.singlePasswordEnabled @pyqtSlot('QJSValue', bool, str) diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py index 8a128b997..3f9fdb5d4 100644 --- a/electrum/gui/wizard.py +++ b/electrum/gui/wizard.py @@ -234,7 +234,7 @@ class NewWalletWizard(AbstractWizard): for addr in data['address_list'].split(): addresses[addr] = {} elif data['keystore_type'] in ['createseed', 'haveseed']: - if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit + if data['seed_type'] in ['old', 'standard', 'segwit']: self._logger.debug('creating keystore from electrum seed') k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') elif data['seed_type'] == 'bip39': @@ -243,7 +243,7 @@ class NewWalletWizard(AbstractWizard): derivation = normalize_bip32_derivation(data['derivation_path']) script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) - elif data['seed_type'] == '2fa_segwit': # TODO: legacy 2fa + elif data['seed_type'] == '2fa_segwit': # TODO: legacy 2fa '2fa' self._logger.debug('creating keystore from 2fa seed') k = keystore.from_xprv(data['x1/']['xprv']) else: @@ -274,7 +274,13 @@ class NewWalletWizard(AbstractWizard): db.put('keystore', k.dump()) elif data['wallet_type'] == '2fa': db.put('x1/', k.dump()) - db.put('x2/', data['x2/']) + if data['trustedcoin_keepordisable'] == 'disable': + k2 = keystore.from_xprv(data['x2/']['xprv']) + if data['encrypt'] and k2.may_have_password(): + k2.update_password(None, data['password']) + db.put('x2/', k2.dump()) + else: + db.put('x2/', data['x2/']) db.put('x3/', data['x3/']) db.put('use_trustedcoin', True) elif data['wallet_type'] == 'imported': diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 364178248..dde9fb929 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,12 +1,13 @@ import threading import socket +import base64 from typing import TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot from electrum.i18n import _ from electrum.plugin import hook -from electrum.bip32 import xpub_type +from electrum.bip32 import xpub_type, BIP32Node from electrum.util import UserFacingException from electrum import keystore @@ -30,9 +31,7 @@ class Plugin(TrustedCoinPlugin): _termsAndConditions = '' termsAndConditionsErrorChanged = pyqtSignal() _termsAndConditionsError = '' - createRemoteKeyErrorChanged = pyqtSignal() - _createRemoteKeyError = '' - otpError = pyqtSignal() + otpError = pyqtSignal([str], arguments=['message']) otpSuccess = pyqtSignal() disclaimerChanged = pyqtSignal() keystoreChanged = pyqtSignal() @@ -41,6 +40,10 @@ class Plugin(TrustedCoinPlugin): shortIdChanged = pyqtSignal() _shortId = '' + _remoteKeyState = '' + remoteKeyStateChanged = pyqtSignal() + remoteKeyError = pyqtSignal([str], arguments=['message']) + requestOtp = pyqtSignal() def __init__(self, plugin, parent): @@ -81,9 +84,15 @@ class Plugin(TrustedCoinPlugin): def termsAndConditionsError(self): return self._termsAndConditionsError - @pyqtProperty(str, notify=createRemoteKeyErrorChanged) - def createRemoteKeyError(self): - return self._createRemoteKeyError + @pyqtProperty(str, notify=remoteKeyStateChanged) + def remoteKeyState(self): + return self._remoteKeyState + + @remoteKeyState.setter + def remoteKeyState(self, new_state): + if self._remoteKeyState != new_state: + self._remoteKeyState = new_state + self.remoteKeyStateChanged.emit() @pyqtSlot() def fetchTermsAndConditions(self): @@ -112,7 +121,8 @@ class Plugin(TrustedCoinPlugin): @pyqtSlot(str) def createKeystore(self, email): - xprv1, xpub1, xpub2, xpub3, short_id = self.plugin.create_keys() + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() def create_remote_key_task(): try: self.plugin.logger.debug('create remote key') @@ -121,25 +131,32 @@ class Plugin(TrustedCoinPlugin): otp_secret = r['otp_secret'] _xpub3 = r['xpubkey_cosigner'] _id = r['id'] - except (socket.error, ErrorConnectingServer): - self._createRemoteKeyError = _('Error creating key') - self.createRemoteKeyErrorChanged.emit() + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') except TrustedCoinException as e: - # if e.status_code == 409: TODO ? - # r = None - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + if e.status_code == 409: + self.remoteKeyState = 'wallet_known' + self._shortId = short_id + self.shortIdChanged.emit() + else: + self.remoteKeyState = 'error' + self.logger.warning(str(e)) + self.remoteKeyError.emit(f'Service error: {str(e)}') except (KeyError,TypeError) as e: # catch any assumptions - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + self.logger.error(str(e)) else: if short_id != _id: - self._createRemoteKeyError = "unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) + self.remoteKeyError.emit('Unexpected short_id') return if xpub3 != _xpub3: - self._createRemoteKeyError = "unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) + self.remoteKeyError.emit('Unexpected trustedcoin xpub3') return self._otpSecret = otp_secret self.otpSecretChanged.emit() @@ -151,10 +168,49 @@ class Plugin(TrustedCoinPlugin): self._busy = True self.busyChanged.emit() + t = threading.Thread(target=create_remote_key_task) t.daemon = True t.start() + @pyqtSlot() + def resetOtpSecret(self): + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() + def reset_otp_task(): + try: + self.plugin.logger.debug('reset_otp') + r = server.get_challenge(short_id) + challenge = r.get('challenge') + message = 'TRUSTEDCOIN CHALLENGE: ' + challenge + def f(xprv): + rootnode = BIP32Node.from_xkey(xprv) + key = rootnode.subkey_at_private_derivation((0, 0)).eckey + sig = key.sign_message(message, True) + return base64.b64encode(sig).decode() + + signatures = [f(x) for x in [xprv1, xprv2]] + r = server.reset_auth(short_id, challenge, signatures) + otp_secret = r.get('otp_secret') + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except Exception as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + else: + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=reset_otp_task, daemon=True) + t.start() + @pyqtSlot(str, int) def checkOtp(self, short_id, otp): def check_otp_task(): @@ -164,15 +220,13 @@ class Plugin(TrustedCoinPlugin): except TrustedCoinException as e: if e.status_code == 400: # invalid OTP self.plugin.logger.debug('Invalid one-time password.') - self.otpError.emit() + self.otpError.emit(_('Invalid one-time password.')) else: self.plugin.logger.error(str(e)) - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.otpError.emit(f'Service error: {str(e)}') except Exception as e: self.plugin.logger.error(str(e)) - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.otpError.emit(f'Error: {str(e)}') else: self.plugin.logger.debug('OTP verify success') self.otpSuccess.emit() @@ -182,8 +236,7 @@ class Plugin(TrustedCoinPlugin): self._busy = True self.busyChanged.emit() - t = threading.Thread(target=check_otp_task) - t.daemon = True + t = threading.Thread(target=check_otp_task, daemon=True) t.start() @@ -204,6 +257,7 @@ class Plugin(TrustedCoinPlugin): _('This wallet was restored from seed, and it contains two master private keys.'), _('Therefore, two-factor authentication is disabled.') ]) + self.logger.info(msg) #action = lambda: window.show_message(msg) #else: #action = partial(self.settings_dialog, window) @@ -233,7 +287,8 @@ class Plugin(TrustedCoinPlugin): }, 'trustedcoin_choose_seed': { 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', - 'next': self.on_choose_seed + 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' + else 'trustedcoin_have_seed' }, 'trustedcoin_create_seed': { 'gui': 'WCCreateSeed', @@ -245,7 +300,14 @@ class Plugin(TrustedCoinPlugin): }, 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', - 'next': 'trustedcoin_tos_email' + 'next': 'trustedcoin_keep_disable' + }, + 'trustedcoin_keep_disable': { + 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable', + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda v,d: wizard.last_if_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, 'trustedcoin_tos_email': { 'gui': '../../../../plugins/trustedcoin/qml/Terms', @@ -260,12 +322,6 @@ class Plugin(TrustedCoinPlugin): } wizard.navmap_merge(views) - def on_choose_seed(self, wizard_data): - self.logger.debug('on_choose_seed') - if wizard_data['keystore_type'] == 'createseed': - return 'trustedcoin_create_seed' - else: - return 'trustedcoin_have_seed' # combined create_keystore and create_remote_key pre def create_keys(self): @@ -286,21 +342,33 @@ class Plugin(TrustedCoinPlugin): xtype = xpub_type(xpub1) xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - return (xprv1,xpub1,xpub2,xpub3,short_id) + return (xprv1,xpub1,xprv2,xpub2,xpub3,short_id) def on_accept_otp_secret(self, wizard_data): - self.logger.debug('on accept otp: ' + repr(wizard_data)) + self.logger.debug('OTP secret accepted, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xpub(xpub2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() - xprv1,xpub1,xpub2,xpub3,short_id = self.create_keys() + def recovery_disable(self, wizard_data): + if wizard_data['trustedcoin_keepordisable'] != 'disable': + return + self.logger.debug('2fa disabled, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) + k2 = keystore.from_xprv(xprv2) k3 = keystore.from_xpub(xpub3) wizard_data['x1/'] = k1.dump() wizard_data['x2/'] = k2.dump() wizard_data['x3/'] = k3.dump() - # wizard_data['use_trustedcoin'] = True + # regular wallet prompt functions diff --git a/electrum/plugins/trustedcoin/qml/KeepDisable.qml b/electrum/plugins/trustedcoin/qml/KeepDisable.qml new file mode 100644 index 000000000..3e10d5175 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/KeepDisable.qml @@ -0,0 +1,35 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: keepordisablegroup.checkedButton + + function apply() { + wizard_data['trustedcoin_keepordisable'] = keepordisablegroup.checkedButton.keepordisable + } + + ButtonGroup { + id: keepordisablegroup + onCheckedButtonChanged: checkIsLast() + } + + ColumnLayout { + Label { + text: qsTr('Restore 2FA wallet') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'keep' + checked: true + text: qsTr('Keep') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'disable' + text: qsTr('Disable') + } + } +} diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml index da2e2eb0b..849111a12 100644 --- a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml +++ b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml @@ -12,6 +12,10 @@ WizardComponent { property bool otpVerified: false + function apply() { + wizard_data['trustedcoin_new_otp_secret'] = requestNewSecret.checked + } + ColumnLayout { width: parent.width @@ -20,16 +24,24 @@ WizardComponent { } InfoTextArea { + id: errorBox iconStyle: InfoTextArea.IconStyle.Error - visible: plugin ? plugin.createRemoteKeyError : false - text: plugin ? plugin.createRemoteKeyError : '' + visible: !otpVerified && plugin.remoteKeyState == 'error' + } + + InfoTextArea { + iconStyle: InfoTextArea.IconStyle.Warn + visible: plugin.remoteKeyState == 'wallet_known' + text: qsTr('This wallet is already registered with TrustedCoin. ') + + qsTr('To finalize wallet creation, please enter your Google Authenticator Code. ') } QRImage { Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == '' qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name'] + '?secret=' + plugin.otpSecret + '&digits=6') - render: plugin ? plugin.otpSecret : false + render: plugin.otpSecret } TextHighlightPane { @@ -43,17 +55,24 @@ WizardComponent { } Label { + visible: !otpVerified && plugin.otpSecret Layout.preferredWidth: parent.width wrapMode: Text.Wrap text: qsTr('Enter or scan into authenticator app. Then authenticate below') - visible: plugin.otpSecret && !otpVerified + } + + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('If you still have your OTP secret, then authenticate below') } TextField { id: otp_auth + visible: !otpVerified && (plugin.otpSecret || plugin.remoteKeyState == 'wallet_known') Layout.alignment: Qt.AlignHCenter focus: true - visible: plugin.otpSecret && !otpVerified inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly font.family: FixedFont font.pixelSize: constants.fontSizeLarge @@ -65,12 +84,26 @@ WizardComponent { } } + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('Otherwise, you can request your OTP secret from the server, by pressing the button below') + } + + Button { + Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == 'wallet_known' && !otpVerified + text: qsTr('Request OTP secret') + onClicked: plugin.resetOtpSecret() + } + Image { Layout.alignment: Qt.AlignHCenter source: '../../../gui/icons/confirmed.png' visible: otpVerified - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge } } @@ -88,14 +121,17 @@ WizardComponent { Connections { target: plugin - function onOtpError() { + function onOtpError(message) { console.log('OTP verify error') - // TODO: show error in UI + errorBox.text = message } function onOtpSuccess() { console.log('OTP verify success') otpVerified = true } + function onRemoteKeyError(message) { + errorBox.text = message + } } }