From 571d16314f64a55cea4877cfb559ca754c34a94f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 3 Aug 2023 20:43:16 +0200 Subject: [PATCH] qt: introduce electrum/gui/qt_common, implement remaining trustedcoin views, unify most qml and qt wizard code for trustedcoin, separate non-GUI trustedcoin wizard definition to trustedcoin.py --- electrum/gui/qt/wizard/wizard.py | 20 +- electrum/gui/qt_common/__init__.py | 0 electrum/gui/{qml => qt_common}/plugins.py | 6 +- electrum/plugins/labels/qml.py | 2 +- electrum/plugins/trustedcoin/qml.py | 319 +------------------- electrum/plugins/trustedcoin/qt.py | 230 +++++++++++--- electrum/plugins/trustedcoin/qt_common.py | 248 +++++++++++++++ electrum/plugins/trustedcoin/trustedcoin.py | 88 ++++++ 8 files changed, 558 insertions(+), 355 deletions(-) create mode 100644 electrum/gui/qt_common/__init__.py rename electrum/gui/{qml => qt_common}/plugins.py (90%) create mode 100644 electrum/plugins/trustedcoin/qt_common.py diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py index b07c30e48..9af4171d0 100644 --- a/electrum/gui/qt/wizard/wizard.py +++ b/electrum/gui/qt/wizard/wizard.py @@ -3,12 +3,12 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QSize from PyQt5.QtGui import QPixmap -from PyQt5.QtWidgets import (QDialog, QApplication, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea, +from PyQt5.QtWidgets import (QDialog, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea, QHBoxLayout, QLayout, QStackedWidget) from electrum.i18n import _ -from ..util import Buttons, icon_path from electrum.logging import get_logger +from electrum.gui.qt.util import Buttons, icon_path if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -165,7 +165,9 @@ class QEAbstractWizard(QDialog): def on_back_button_clicked(self): if self.can_go_back(): self.prev() - self.main_widget.removeWidget(self.main_widget.currentWidget()) + widget = self.main_widget.currentWidget() + self.main_widget.removeWidget(widget) + widget.deleteLater() self.update() else: self.close() @@ -212,7 +214,7 @@ class WizardComponent(QWidget): self.wizard_data = {} self.title = title if title is not None else 'No title' self.wizard = wizard - self.error = '' + self._error = '' self._valid = False self._busy = False @@ -236,6 +238,16 @@ class WizardComponent(QWidget): self._busy = is_busy self.on_updated() + @property + def error(self): + return self._error + + @error.setter + def error(self, error): + if self._error != error: + self._error = error + self.on_updated() + @abstractmethod def apply(self): # called to apply UI component values to wizard_data diff --git a/electrum/gui/qt_common/__init__.py b/electrum/gui/qt_common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/electrum/gui/qml/plugins.py b/electrum/gui/qt_common/plugins.py similarity index 90% rename from electrum/gui/qml/plugins.py rename to electrum/gui/qt_common/plugins.py index cee411a10..ba2189dd2 100644 --- a/electrum/gui/qml/plugins.py +++ b/electrum/gui/qt_common/plugins.py @@ -1,8 +1,8 @@ -from PyQt5.QtCore import pyqtSignal, pyqtSlot, pyqtProperty, QObject +from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject -from electrum.i18n import _ from electrum.logging import get_logger + class PluginQObject(QObject): logger = get_logger(__name__) @@ -24,6 +24,8 @@ class PluginQObject(QObject): @pyqtProperty(bool, notify=busyChanged) def busy(self): return self._busy + # below only used for QML, not compatible yet with Qt + @pyqtProperty(bool, notify=pluginEnabledChanged) def pluginEnabled(self): return self.plugin.is_enabled() diff --git a/electrum/plugins/labels/qml.py b/electrum/plugins/labels/qml.py index 4ad17263f..a22cb2a1c 100644 --- a/electrum/plugins/labels/qml.py +++ b/electrum/plugins/labels/qml.py @@ -6,7 +6,7 @@ from electrum.i18n import _ from electrum.plugin import hook from electrum.gui.qml.qewallet import QEWallet -from electrum.gui.qml.plugins import PluginQObject +from electrum.gui.qt_common.plugins import PluginQObject from .labels import LabelsPlugin diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 6d8c15589..602597e3d 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,261 +1,22 @@ -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, BIP32Node from electrum.util import UserFacingException -from electrum import keystore from electrum.gui.qml.qewallet import QEWallet -from electrum.gui.qml.plugins import PluginQObject +from .qt_common import QSignalObject -from .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, - MOBILE_DISCLAIMER, get_user_id, get_signing_xpub, - TrustedCoinException, make_xpub) +from .trustedcoin import TrustedCoinPlugin, TrustedCoinException if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication from electrum.wallet import Abstract_Wallet + from electrum.wizard import NewWalletWizard class Plugin(TrustedCoinPlugin): - class QSignalObject(PluginQObject): - canSignWithoutServerChanged = pyqtSignal() - _canSignWithoutServer = False - termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message']) - termsAndConditionsError = pyqtSignal([str], arguments=['message']) - otpError = pyqtSignal([str], arguments=['message']) - otpSuccess = pyqtSignal() - disclaimerChanged = pyqtSignal() - keystoreChanged = pyqtSignal() - otpSecretChanged = pyqtSignal() - _otpSecret = '' - shortIdChanged = pyqtSignal() - _shortId = '' - billingModelChanged = pyqtSignal() - _billingModel = [] - - _remoteKeyState = '' - remoteKeyStateChanged = pyqtSignal() - remoteKeyError = pyqtSignal([str], arguments=['message']) - - requestOtp = pyqtSignal() - - def __init__(self, plugin, parent): - super().__init__(plugin, parent) - - @pyqtProperty(str, notify=disclaimerChanged) - def disclaimer(self): - return '\n\n'.join(MOBILE_DISCLAIMER) - - @pyqtProperty(bool, notify=canSignWithoutServerChanged) - def canSignWithoutServer(self): - return self._canSignWithoutServer - - @pyqtProperty('QVariantMap', notify=keystoreChanged) - def keystore(self): - return self._keystore - - @pyqtProperty(str, notify=otpSecretChanged) - def otpSecret(self): - return self._otpSecret - - @pyqtProperty(str, notify=shortIdChanged) - def shortId(self): - return self._shortId - - @pyqtSlot(str) - def otpSubmit(self, otp): - self._plugin.on_otp(otp) - - @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() - - @pyqtProperty('QVariantList', notify=billingModelChanged) - def billingModel(self): - return self._billingModel - - def updateBillingInfo(self, wallet): - billingModel = [] - - price_per_tx = wallet.price_per_tx - for k, v in sorted(price_per_tx.items()): - if k == 1: - continue - item = { - 'text': 'Pay every %d transactions' % k, - 'value': k, - 'sats_per_tx': v/k - } - billingModel.append(item) - - self._billingModel = billingModel - self.billingModelChanged.emit() - - @pyqtSlot() - def fetchTermsAndConditions(self): - def fetch_task(): - try: - self.plugin.logger.debug('TOS') - tos = server.get_terms_of_service() - except ErrorConnectingServer as e: - self.termsAndConditionsError.emit(_('Error connecting to server')) - except Exception as e: - self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e))) - else: - self.termsAndConditionsRetrieved.emit(tos) - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=fetch_task) - t.daemon = True - t.start() - - @pyqtSlot(str) - def createKeystore(self, email): - self.remoteKeyState = '' - self._otpSecret = '' - self.otpSecretChanged.emit() - - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() - - def create_remote_key_task(): - try: - self.plugin.logger.debug('create remote key') - r = server.create(xpub1, xpub2, email) - - otp_secret = r['otp_secret'] - _xpub3 = r['xpubkey_cosigner'] - _id = r['id'] - 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: - 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.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Error: {str(e)}') - self.logger.error(str(e)) - else: - if short_id != _id: - 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.remoteKeyState = 'error' - self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) - self.remoteKeyError.emit('Unexpected trustedcoin xpub3') - return - self.remoteKeyState = 'new' - self._otpSecret = otp_secret - self.otpSecretChanged.emit() - self._shortId = short_id - self.shortIdChanged.emit() - finally: - self._busy = False - self.busyChanged.emit() - - 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: - # TODO: move reset request to UI agnostic plugin section - 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.remoteKeyState = 'reset' - 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(): - try: - self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') - server.auth(short_id, otp) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - self.plugin.logger.debug('Invalid one-time password.') - self.otpError.emit(_('Invalid one-time password.')) - else: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Service error: {str(e)}') - except Exception as e: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Error: {str(e)}') - else: - self.plugin.logger.debug('OTP verify success') - self.otpSuccess.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=check_otp_task, daemon=True) - t.start() - def __init__(self, *args): super().__init__(*args) @@ -279,108 +40,45 @@ class Plugin(TrustedCoinPlugin): def init_qml(self, app: 'ElectrumQmlApplication'): self.logger.debug(f'init_qml hook called, gui={str(type(app))}') self._app = app + wizard = self._app.daemon.newWalletWizard # important: QSignalObject needs to be parented, as keeping a ref # in the plugin is not enough to avoid gc - self.so = Plugin.QSignalObject(self, self._app) - + self.so = QSignalObject(self, wizard, self._app) # extend wizard - self.extend_wizard() + self.extend_wizard(wizard) # wizard support functions - def extend_wizard(self): - wizard = self._app.daemon.newWalletWizard - self.logger.debug(repr(wizard)) + def extend_wizard(self, wizard: 'NewWalletWizard'): + super().extend_wizard(wizard) views = { 'trustedcoin_start': { 'gui': '../../../../plugins/trustedcoin/qml/Disclaimer', - 'next': 'trustedcoin_choose_seed' }, 'trustedcoin_choose_seed': { 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', - 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' - else 'trustedcoin_have_seed' }, 'trustedcoin_create_seed': { 'gui': 'WCCreateSeed', - 'next': 'trustedcoin_confirm_seed' }, 'trustedcoin_confirm_seed': { 'gui': 'WCConfirmSeed', - 'next': 'trustedcoin_tos_email' }, 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', - '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 d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, 'trustedcoin_tos_email': { 'gui': '../../../../plugins/trustedcoin/qml/Terms', - 'next': 'trustedcoin_show_confirm_otp' }, 'trustedcoin_show_confirm_otp': { 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP', - 'accept': self.on_accept_otp_secret, - 'next': 'wallet_password', - 'last': lambda d: wizard.is_single_password() } } wizard.navmap_merge(views) - - # combined create_keystore and create_remote_key pre - def create_keys(self): - wizard = self._app.daemon.newWalletWizard - wizard_data = wizard._current.wizard_data - - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], wizard_data['seed_extra_words']) - - # NOTE: at this point, old style wizard creates a wallet file (w. password if set) and - # stores the keystores and wizard state, in order to separate offline seed creation - # and online retrieval of the OTP secret. For mobile, we don't do this, but - # for desktop the wizard should support this usecase. - - data = {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}} - - # Generate third key deterministically. - long_user_id, short_id = get_user_id(data) - xtype = xpub_type(xpub1) - xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - - return (xprv1,xpub1,xprv2,xpub2,xpub3,short_id) - - def on_accept_otp_secret(self, 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() - - 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_xprv(xprv2) - k3 = keystore.from_xpub(xpub3) - - wizard_data['x1/'] = k1.dump() - wizard_data['x2/'] = k2.dump() - wizard_data['x3/'] = k3.dump() - - # running wallet functions def prompt_user_for_otp(self, wallet, tx, on_success, on_failure): @@ -418,4 +116,3 @@ class Plugin(TrustedCoinPlugin): qewallet = QEWallet.getInstanceFor(wallet) qewallet.billingInfoChanged.emit() self.so.updateBillingInfo(wallet) - diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 4ba05b2c1..d5b84735f 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -30,29 +30,38 @@ import os from typing import TYPE_CHECKING from PyQt5.QtGui import QPixmap -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtCore import QObject, pyqtSignal, QTimer from PyQt5.QtWidgets import (QTextEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout, - QRadioButton, QCheckBox, QLineEdit) + QRadioButton, QCheckBox, QLineEdit, QPushButton, QWidget) +from electrum.i18n import _ +from electrum import keystore +from electrum.bip32 import xpub_type +from electrum.plugin import hook +from electrum.util import is_valid_email +from electrum.logging import Logger, get_logger +from electrum.base_wizard import GoBack, UserCancelled + +from .qt_common import QSignalObject from electrum.gui.qt.util import (read_QIcon, WindowModalDialog, WaitingDialog, OkButton, CancelButton, Buttons, icon_path, WWLabel, CloseButton, ChoicesLayout) from electrum.gui.qt.qrcodewidget import QRCodeWidget from electrum.gui.qt.amountedit import AmountEdit from electrum.gui.qt.main_window import StatusBarButton from electrum.gui.qt.installwizard import InstallWizard -from electrum.i18n import _ -from electrum.plugin import hook -from electrum.util import is_valid_email -from electrum.logging import Logger -from electrum.base_wizard import GoBack, UserCancelled +from electrum.gui.qt.wizard.wallet import WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt +from electrum.gui.qt.wizard.wizard import WizardComponent + +# from .trustedcoin import TrustedCoinPlugin, server, DISCLAIMER, make_xpub, get_signing_xpub, get_user_id +from .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, + DISCLAIMER, get_user_id, get_signing_xpub, + TrustedCoinException, make_xpub) -from .trustedcoin import TrustedCoinPlugin, server, DISCLAIMER -from ...gui.qt.wizard.wallet import WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt -from ...gui.qt.wizard.wizard import WizardComponent if TYPE_CHECKING: from electrum.gui.qt.main_window import ElectrumWindow from electrum.wallet import Abstract_Wallet + from electrum.wizard import NewWalletWizard class TOS(QTextEdit): @@ -336,56 +345,47 @@ class Plugin(TrustedCoinPlugin): @hook def init_wallet_wizard(self, wizard: 'QEWalletWizard'): + # FIXME: self.so is currently scoped to plugin, which is shared among wizards. This is wrong + # refactor to be a member of the wizard instance + self.so = QSignalObject(self, wizard, None) self.extend_wizard(wizard) + self._wizard = wizard - def extend_wizard(self, wizard): - # wizard = self._app.daemon.newWalletWizard - # self.logger.debug(repr(wizard)) - # TODO: move non-gui parts to base plugin + def extend_wizard(self, wizard: 'NewWalletWizard'): + super().extend_wizard(wizard) views = { 'trustedcoin_start': { 'gui': WCDisclaimer, 'params': {'icon': icon_path('trustedcoin-wizard.png')}, - 'next': 'trustedcoin_choose_seed' }, 'trustedcoin_choose_seed': { 'gui': WCChooseSeed, 'params': {'icon': icon_path('trustedcoin-wizard.png')}, - 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' - else 'trustedcoin_have_seed' }, 'trustedcoin_create_seed': { 'gui': WCCreateSeed, 'params': {'icon': icon_path('trustedcoin-wizard.png')}, - 'next': 'trustedcoin_confirm_seed' }, 'trustedcoin_confirm_seed': { 'gui': WCConfirmSeed, 'params': {'icon': icon_path('trustedcoin-wizard.png')}, - 'next': 'trustedcoin_tos_email' }, 'trustedcoin_have_seed': { 'gui': WCHaveSeed, 'params': {'icon': icon_path('trustedcoin-wizard.png')}, - '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 d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' - # }, - # 'trustedcoin_tos_email': { - # 'gui': '../../../../plugins/trustedcoin/qml/Terms', - # 'next': 'trustedcoin_show_confirm_otp' - # }, - # 'trustedcoin_show_confirm_otp': { - # 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP', - # 'accept': self.on_accept_otp_secret, - # 'next': 'wallet_password', - # 'last': lambda d: wizard.is_single_password() - # } + 'trustedcoin_keep_disable': { + 'gui': WCKeepDisable, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_tos_email': { + 'gui': WCTerms, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + }, + 'trustedcoin_show_confirm_otp': { + 'gui': WCShowConfirmOTP, + 'params': {'icon': icon_path('trustedcoin-wizard.png')}, + } } wizard.navmap_merge(views) @@ -451,3 +451,159 @@ class WCChooseSeed(WizardComponent): def apply(self): self.wizard_data['keystore_type'] = self.c_values[self.clayout.selected_index()] + + +class WCTerms(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Terms and conditions')) + self.plugin = wizard.plugins.get_plugin('trustedcoin') + self._has_tos = False + + def on_ready(self): + self.tos_e = TOS() + self.tos_e.setReadOnly(True) + self.layout().addWidget(self.tos_e) + + self.layout().addWidget(QLabel(_("Please enter your e-mail address"))) + self.email_e = QLineEdit() + self.email_e.textChanged.connect(self.validate) + self.layout().addWidget(self.email_e) + + self.fetch_terms_and_conditions() + + def fetch_terms_and_conditions(self): + self.plugin.so.busyChanged.connect(self.on_busy_changed) + self.plugin.so.termsAndConditionsRetrieved.connect(self.on_terms_retrieved) + self.plugin.so.termsAndConditionsError.connect(self.on_terms_error) + self.plugin.so.fetchTermsAndConditions() + + def on_busy_changed(self): + self.busy = self.plugin.so.busy + + def on_terms_retrieved(self, tos: str) -> None: + self._has_tos = True + self.tos_e.setText(tos) + self.email_e.setFocus(True) + self.validate() + + def on_terms_error(self, error: str) -> None: + self.error = error + + def validate(self): + if self._has_tos and self.email_e.text() != '': + self.valid = True + else: + self.valid = False + + def apply(self): + self.wizard_data['2fa_email'] = self.email_e.text() + + +class WCShowConfirmOTP(WizardComponent): + _logger = get_logger(__name__) + + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Authenticator secret')) + self.plugin = wizard.plugins.get_plugin('trustedcoin') + + self.new_otp = QWidget() + new_otp_layout = QVBoxLayout() + scanlabel = WWLabel(_('Enter or scan into authenticator app. Then authenticate below')) + new_otp_layout.addWidget(scanlabel) + self.qr = QRCodeWidget('') + new_otp_layout.addWidget(self.qr) + self.secretlabel = WWLabel() + new_otp_layout.addWidget(self.secretlabel) + self.new_otp.setLayout(new_otp_layout) + + self.exist_otp = QWidget() + exist_otp_layout = QVBoxLayout() + knownlabel = WWLabel(_('This wallet is already registered with TrustedCoin.')) + exist_otp_layout.addWidget(knownlabel) + knownsecretlabel = WWLabel(_('If you still have your OTP secret, then authenticate below to finalize wallet creation')) + exist_otp_layout.addWidget(knownsecretlabel) + self.exist_otp.setLayout(exist_otp_layout) + + self.authlabelnew = WWLabel(_('Then, enter your Google Authenticator code:')) + self.authlabelexist = WWLabel(_('Google Authenticator code:')) + + self.resetlabel = WWLabel(_('If you have lost your OTP secret, click the button below to request a new secret from the server.')) + self.button = QPushButton('Request OTP secret') + self.button.clicked.connect(self.on_request_otp) + + hbox = QHBoxLayout() + hbox.addWidget(self.authlabelnew) + hbox.addWidget(self.authlabelexist) + pw = AmountEdit(None, is_int = True) + pw.setFocus(True) + pw.setMaximumWidth(150) + hbox.addWidget(pw) + # hbox.addStretch(1) + + self.layout().addWidget(self.new_otp) + self.layout().addWidget(self.exist_otp) + self.layout().addLayout(hbox) + self.layout().addWidget(self.resetlabel) + self.layout().addWidget(self.button) + self.layout().addStretch(1) + + def on_ready(self): + self.plugin.so.busyChanged.connect(self.on_busy_changed) + self.plugin.so.remoteKeyError.connect(self.on_remote_key_error) + self.plugin.so.createKeystore(self.wizard_data['2fa_email']) + + def update(self): + is_new = bool(self.plugin.so.remoteKeyState != 'wallet_known') + self.new_otp.setVisible(is_new) + self.exist_otp.setVisible(not is_new) + self.authlabelnew.setVisible(is_new) + self.authlabelexist.setVisible(not is_new) + self.resetlabel.setVisible(not is_new) + self.button.setVisible(not is_new) + + if self.plugin.so.otpSecret: + self.secretlabel.setText(self.plugin.so.otpSecret) + uri = 'otpauth://totp/Electrum 2FA %s?secret=%s&digits=6' % ( + self.wizard_data['wallet_name'], self.plugin.so.otpSecret) + self.qr.setData(uri) + + def on_busy_changed(self): + self.busy = self.plugin.so.busy + if not self.busy: + self.update() + + def on_remote_key_error(self, text): + self._logger.error(text) + self.error = text + + def on_request_otp(self): + self.plugin.so.resetOtpSecret() + self.update() + + def apply(self): + pass + + +class WCKeepDisable(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet')) + message = ' '.join([ + 'You are going to restore a wallet protected with two-factor authentication.', + 'Do you want to keep using two-factor authentication with this wallet,', + 'or do you want to disable it, and have two master private keys in your wallet?' + ]) + choices = [ + ('keep', _('Keep')), + ('disable', _('Disable')), + ] + + self.c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + self.clayout = ChoicesLayout(message, c_titles) + self.layout().addLayout(self.clayout.layout()) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['trustedcoin_keepordisable'] = self.c_values[self.clayout.selected_index()] diff --git a/electrum/plugins/trustedcoin/qt_common.py b/electrum/plugins/trustedcoin/qt_common.py new file mode 100644 index 000000000..61aec8bcd --- /dev/null +++ b/electrum/plugins/trustedcoin/qt_common.py @@ -0,0 +1,248 @@ +import threading +import socket +import base64 + +from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot + +from electrum.i18n import _ +from electrum.bip32 import BIP32Node + +from .trustedcoin import (server, ErrorConnectingServer, MOBILE_DISCLAIMER, TrustedCoinException) +from electrum.gui.qt_common.plugins import PluginQObject + + +class QSignalObject(PluginQObject): + canSignWithoutServerChanged = pyqtSignal() + _canSignWithoutServer = False + termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message']) + termsAndConditionsError = pyqtSignal([str], arguments=['message']) + otpError = pyqtSignal([str], arguments=['message']) + otpSuccess = pyqtSignal() + disclaimerChanged = pyqtSignal() + keystoreChanged = pyqtSignal() + otpSecretChanged = pyqtSignal() + _otpSecret = '' + shortIdChanged = pyqtSignal() + _shortId = '' + billingModelChanged = pyqtSignal() + _billingModel = [] + + _remoteKeyState = '' + remoteKeyStateChanged = pyqtSignal() + remoteKeyError = pyqtSignal([str], arguments=['message']) + + requestOtp = pyqtSignal() + + def __init__(self, plugin, wizard, parent): + super().__init__(plugin, parent) + self.wizard = wizard + + @pyqtProperty(str, notify=disclaimerChanged) + def disclaimer(self): + return '\n\n'.join(MOBILE_DISCLAIMER) + + @pyqtProperty(bool, notify=canSignWithoutServerChanged) + def canSignWithoutServer(self): + return self._canSignWithoutServer + + @pyqtProperty('QVariantMap', notify=keystoreChanged) + def keystore(self): + return self._keystore + + @pyqtProperty(str, notify=otpSecretChanged) + def otpSecret(self): + return self._otpSecret + + @pyqtProperty(str, notify=shortIdChanged) + def shortId(self): + return self._shortId + + @pyqtSlot(str) + def otpSubmit(self, otp): + self._plugin.on_otp(otp) + + @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() + + @pyqtProperty('QVariantList', notify=billingModelChanged) + def billingModel(self): + return self._billingModel + + def updateBillingInfo(self, wallet): + billingModel = [] + + price_per_tx = wallet.price_per_tx + for k, v in sorted(price_per_tx.items()): + if k == 1: + continue + item = { + 'text': 'Pay every %d transactions' % k, + 'value': k, + 'sats_per_tx': v / k + } + billingModel.append(item) + + self._billingModel = billingModel + self.billingModelChanged.emit() + + @pyqtSlot() + def fetchTermsAndConditions(self): + def fetch_task(): + try: + self.plugin.logger.debug('TOS') + tos = server.get_terms_of_service() + except ErrorConnectingServer as e: + self.termsAndConditionsError.emit(_('Error connecting to server')) + except Exception as e: + self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e))) + else: + self.termsAndConditionsRetrieved.emit(tos) + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=fetch_task) + t.daemon = True + t.start() + + @pyqtSlot(str) + def createKeystore(self, email): + self.remoteKeyState = '' + self._otpSecret = '' + self.otpSecretChanged.emit() + + wizard_data = self.wizard._current.wizard_data + + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) + + def create_remote_key_task(): + try: + self.plugin.logger.debug('create remote key') + r = server.create(xpub1, xpub2, email) + + otp_secret = r['otp_secret'] + _xpub3 = r['xpubkey_cosigner'] + _id = r['id'] + 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: + 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.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + self.logger.error(str(e)) + else: + if short_id != _id: + 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.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) + self.remoteKeyError.emit('Unexpected trustedcoin xpub3') + return + self.remoteKeyState = 'new' + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + self._shortId = short_id + self.shortIdChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + 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 = '' + + wizard_data = self.wizard._current.wizard_data + + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) + + 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.remoteKeyState = 'reset' + 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(): + try: + self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') + server.auth(short_id, otp) + except TrustedCoinException as e: + if e.status_code == 400: # invalid OTP + self.plugin.logger.debug('Invalid one-time password.') + self.otpError.emit(_('Invalid one-time password.')) + else: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Service error: {str(e)}') + except Exception as e: + self.plugin.logger.error(str(e)) + self.otpError.emit(f'Error: {str(e)}') + else: + self.plugin.logger.debug('OTP verify success') + self.otpSuccess.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + t = threading.Thread(target=check_otp_task, daemon=True) + t.start() diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index a090e30f9..84424a07d 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -783,3 +783,91 @@ class TrustedCoinPlugin(BasePlugin): return self, 'show_disclaimer' if not db.get('x3/'): return self, 'accept_terms_of_use' + + # new wizard + + # insert trustedcoin pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + # wizard = self._app.daemon.newWalletWizard + # self.logger.debug(repr(wizard)) + views = { + 'trustedcoin_start': { + 'next': 'trustedcoin_choose_seed', + }, + 'trustedcoin_choose_seed': { + 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' + else 'trustedcoin_have_seed' + }, + 'trustedcoin_create_seed': { + 'next': 'trustedcoin_confirm_seed' + }, + 'trustedcoin_confirm_seed': { + 'next': 'trustedcoin_tos_email' + }, + 'trustedcoin_have_seed': { + 'next': 'trustedcoin_keep_disable' + }, + 'trustedcoin_keep_disable': { + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' + }, + 'trustedcoin_tos_email': { + 'next': 'trustedcoin_show_confirm_otp' + }, + 'trustedcoin_show_confirm_otp': { + 'accept': self.on_accept_otp_secret, + 'next': 'wallet_password', + 'last': lambda d: wizard.is_single_password() + } + } + wizard.navmap_merge(views) + + # combined create_keystore and create_remote_key pre + def create_keys(self, wizard_data): + # wizard = self._app.daemon.newWalletWizard + # wizard = self._wizard + # wizard_data = wizard._current.wizard_data + + xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], wizard_data['seed_extra_words']) + + # NOTE: at this point, old style wizard creates a wallet file (w. password if set) and + # stores the keystores and wizard state, in order to separate offline seed creation + # and online retrieval of the OTP secret. For mobile, we don't do this, but + # for desktop the wizard should support this usecase. + + data = {'x1/': {'xpub': xpub1}, 'x2/': {'xpub': xpub2}} + + # Generate third key deterministically. + long_user_id, short_id = get_user_id(data) + xtype = xpub_type(xpub1) + xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) + + return xprv1, xpub1, xprv2, xpub2, xpub3, short_id + + def on_accept_otp_secret(self, wizard_data): + self.logger.debug('OTP secret accepted, creating keystores') + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) + 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() + + 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(wizard_data) + k1 = keystore.from_xprv(xprv1) + 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() +