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 .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, MOBILE_DISCLAIMER, get_user_id, get_signing_xpub, TrustedCoinException, make_xpub) if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication from electrum.wallet import Abstract_Wallet 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) @hook def load_wallet(self, wallet: 'Abstract_Wallet'): if not isinstance(wallet, self.wallet_class): return self.logger.debug(f'plugin enabled for wallet "{str(wallet)}"') if wallet.can_sign_without_server(): self.so._canSignWithoutServer = True self.so.canSignWithoutServerChanged.emit() msg = ' '.join([ _('This wallet was restored from seed, and it contains two master private keys.'), _('Therefore, two-factor authentication is disabled.') ]) self.logger.info(msg) self.start_request_thread(wallet) @hook def init_qml(self, app: 'ElectrumQmlApplication'): self.logger.debug(f'init_qml hook called, gui={str(type(app))}') self._app = app # 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) # extend wizard self.extend_wizard() # wizard support functions def extend_wizard(self): wizard = self._app.daemon.newWalletWizard self.logger.debug(repr(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): self.logger.debug('prompt_user_for_otp') self.on_success = on_success self.on_failure = on_failure if on_failure else lambda x: self.logger.error(x) self.wallet = wallet self.tx = tx qewallet = QEWallet.getInstanceFor(wallet) qewallet.request_otp(self.on_otp) def on_otp(self, otp): if not otp: self.on_failure(_('No auth code')) return self.logger.debug(f'on_otp {otp} for tx {repr(self.tx)}') try: self.wallet.on_otp(self.tx, otp) except UserFacingException as e: self.on_failure(_('Invalid one-time password.')) except TrustedCoinException as e: if e.status_code == 400: # invalid OTP self.on_failure(_('Invalid one-time password.')) else: self.on_failure(_('Service Error') + ':\n' + str(e)) except Exception as e: self.on_failure(_('Error') + ':\n' + str(e)) else: self.on_success(self.tx) def billing_info_retrieved(self, wallet): self.logger.info('billing_info_retrieved') qewallet = QEWallet.getInstanceFor(wallet) qewallet.billingInfoChanged.emit() self.so.updateBillingInfo(wallet)