Browse Source

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
master
Sander van Grieken 2 years ago
parent
commit
571d16314f
  1. 20
      electrum/gui/qt/wizard/wizard.py
  2. 0
      electrum/gui/qt_common/__init__.py
  3. 6
      electrum/gui/qt_common/plugins.py
  4. 2
      electrum/plugins/labels/qml.py
  5. 319
      electrum/plugins/trustedcoin/qml.py
  6. 230
      electrum/plugins/trustedcoin/qt.py
  7. 248
      electrum/plugins/trustedcoin/qt_common.py
  8. 88
      electrum/plugins/trustedcoin/trustedcoin.py

20
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

0
electrum/gui/qt_common/__init__.py

6
electrum/gui/qml/plugins.py → 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()

2
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

319
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)

230
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()]

248
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()

88
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()

Loading…
Cancel
Save