diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 821de7536..250dc7b58 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -31,10 +31,6 @@ if TYPE_CHECKING: from .qeapp import ElectrumQmlApplication -class UncaughtException(Exception): - pass - - class ElectrumGui(Logger): @profiler diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index cb7bd1af3..b2ceac550 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -13,142 +13,28 @@ Wizard { signal walletCreated - property alias path: walletdb.path + property string path - // State transition functions. These functions are called when the 'Next' - // button is pressed. Depending on the data create the next page - // in the conversation. - - function walletnameDone(d) { - console.log('wallet name done') - var page = _loadNextComponent(components.wallettype, wizard_data) - page.next.connect(function() {wallettypeDone()}) - } - - function wallettypeDone(d) { - console.log('wallet type done') - var page = _loadNextComponent(components.keystore, wizard_data) - page.next.connect(function() {keystoretypeDone()}) - } - - function keystoretypeDone(d) { - console.log('keystore type done') - var page - switch(wizard_data['keystore_type']) { - case 'createseed': - page = _loadNextComponent(components.createseed, wizard_data) - page.next.connect(function() {createseedDone()}) - break - case 'haveseed': - page = _loadNextComponent(components.haveseed, wizard_data) - page.next.connect(function() {haveseedDone()}) - if (wizard_data['seed_type'] != 'bip39' && Daemon.singlePasswordEnabled) - page.last = true - break - case 'masterkey': - page = _loadNextComponent(components.havemasterkey, wizard_data) - page.next.connect(function() {havemasterkeyDone()}) - if (Daemon.singlePasswordEnabled) - page.last = true - break - } - } - - function createseedDone(d) { - console.log('create seed done') - var page = _loadNextComponent(components.confirmseed, wizard_data) - if (Daemon.singlePasswordEnabled) - page.last = true - else - page.next.connect(function() {confirmseedDone()}) - } - - function confirmseedDone(d) { - console.log('confirm seed done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - function haveseedDone(d) { - console.log('have seed done') - if (wizard_data['seed_type'] == 'bip39') { - var page = _loadNextComponent(components.bip39refine, wizard_data) - if (Daemon.singlePasswordEnabled) - page.last = true - else - page.next.connect(function() {bip39refineDone()}) - } else { - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - } - - function bip39refineDone(d) { - console.log('bip39 refine done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - function havemasterkeyDone(d) { - console.log('have master key done') - var page = _loadNextComponent(components.walletpassword, wizard_data) - page.last = true - } - - Item { - id: components - property Component walletname: Component { - WCWalletName {} - } - - property Component wallettype: Component { - WCWalletType {} - } - - property Component keystore: Component { - WCKeystoreType {} - } - - property Component createseed: Component { - WCCreateSeed {} - } - - property Component haveseed: Component { - WCHaveSeed {} - } - - property Component confirmseed: Component { - WCConfirmSeed {} - } - - property Component bip39refine: Component { - WCBIP39Refine {} - } - - property Component havemasterkey: Component { - WCHaveMasterKey {} - } - - property Component walletpassword: Component { - WCWalletPassword {} - } - } + enter: null // disable transition + property QtObject wiz: Daemon.newWalletWizard Component.onCompleted: { - _setWizardData({}) - var start = _loadNextComponent(components.walletname) - start.next.connect(function() {walletnameDone()}) + var view = wiz.start_wizard() + _loadNextComponent(view) } onAccepted: { console.log('Finished new wallet wizard') - walletdb.create_storage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) + wiz.create_storage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) } - WalletDB { - id: walletdb - onCreateSuccess: walletwizard.walletCreated() + Connections { + target: wiz + function onCreateSuccess() { + walletwizard.path = wiz.path + walletwizard.walletCreated() + } } } diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 4b2954d90..0a2100ada 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -129,7 +129,10 @@ Pane { Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } - Label { text: 'txinType'; color: Material.accentColor } + Label { text: 'wallet type'; color: Material.accentColor } + Label { text: Daemon.currentWallet.walletType } + + Label { text: 'txin Type'; color: Material.accentColor } Label { text: Daemon.currentWallet.txinType } Label { text: 'is deterministic'; color: Material.accentColor } @@ -148,7 +151,7 @@ Pane { Label { text: Daemon.currentWallet.isLightning } Label { text: 'has Seed'; color: Material.accentColor } - Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } + Label { text: Daemon.currentWallet.hasSeed } Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index 7c172842e..b7ac856b5 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -12,7 +12,6 @@ WizardComponent { onAccept: { wizard_data['seed'] = seedtext.text - wizard_data['seed_type'] = 'segwit' wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' } @@ -73,11 +72,16 @@ WizardComponent { } Component.onCompleted : { setWarningText(12) - bitcoin.generate_seed() } } } + onReadyChanged: { + if (!ready) + return + bitcoin.generate_seed(wizard_data['seed_type']) + } + Bitcoin { id: bitcoin onGeneratedSeedChanged: { diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index a5487cffa..527de82a1 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -12,6 +12,8 @@ WizardComponent { id: root valid: false + property bool is2fa: false + onAccept: { wizard_data['seed'] = seedtext.text wizard_data['seed_type'] = bitcoin.seed_type @@ -43,7 +45,7 @@ WizardComponent { } function checkValid() { - bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') + bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39', wizard_data['wallet_type']) } Flickable { @@ -58,11 +60,13 @@ WizardComponent { columns: 2 Label { + visible: !is2fa text: qsTr('Seed Type') Layout.fillWidth: true } ComboBox { id: seed_type + visible: !is2fa model: ['Electrum', 'BIP39'/*, 'SLIP39'*/] onActivated: { setSeedTypeHelpText() @@ -91,7 +95,7 @@ WizardComponent { Rectangle { anchors.fill: contentText - color: 'green' + color: root.valid ? 'green' : 'red' border.color: Material.accentColor radius: 2 } @@ -148,4 +152,12 @@ WizardComponent { Component.onCompleted: { setSeedTypeHelpText() } + + onReadyChanged: { + if (!ready) + return + + if (wizard_data['wallet_type'] == '2fa') + root.is2fa = true + } } diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml index e7c02dd61..8a55a0a29 100644 --- a/electrum/gui/qml/components/wizard/WCWalletType.qml +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -6,6 +6,11 @@ WizardComponent { onAccept: { wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype + if (wizard_data['wallet_type'] == 'standard') + wizard_data['seed_type'] = 'segwit' + else if (wizard_data['wallet_type'] == '2fa') + wizard_data['seed_type'] = '2fa_segwit' + // TODO: multisig } ButtonGroup { @@ -22,7 +27,6 @@ WizardComponent { text: qsTr('Standard Wallet') } RadioButton { - enabled: false ButtonGroup.group: wallettypegroup property string wallettype: '2fa' text: qsTr('Wallet with two-factor authentication') diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index 1caf02899..73874590a 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -24,12 +24,19 @@ Dialog { // Here we do some manual binding of page.valid -> pages.pagevalid and // page.last -> pages.lastpage to propagate the state without the binding // going stale. - function _loadNextComponent(comp, wdata={}) { + function _loadNextComponent(view, wdata={}) { // remove any existing pages after current page while (pages.contentChildren[pages.currentIndex+1]) { pages.takeItem(pages.currentIndex+1).destroy() } + var url = Qt.resolvedUrl(wiz.viewToComponent(view)) + console.log(url) + var comp = Qt.createComponent(url) + if (comp.status == Component.Error) { + console.log(comp.errorString()) + return null + } var page = comp.createObject(pages) page.validChanged.connect(function() { pages.pagevalid = page.valid @@ -37,6 +44,21 @@ Dialog { page.lastChanged.connect(function() { pages.lastpage = page.last } ) + page.next.connect(function() { + var newview = wiz.submit(page.wizard_data) + if (newview.view) { + console.log('next view: ' + newview.view) + var newpage = _loadNextComponent(newview.view, newview.wizard_data) + newpage.last = wiz.isLast(newview.wizard_data) + } else { + console.log('END') + } + }) + page.prev.connect(function() { + var wdata = wiz.prev() + console.log('prev view data: ' + JSON.stringify(wdata)) + page.last = wiz.isLast(wdata) + }) Object.assign(page.wizard_data, wdata) // deep copy page.ready = true // signal page it can access wizard_data pages.pagevalid = page.valid @@ -58,10 +80,12 @@ Dialog { clip:true function prev() { + currentItem.prev() currentIndex = currentIndex - 1 _setWizardData(pages.contentChildren[currentIndex].wizard_data) pages.pagevalid = pages.contentChildren[currentIndex].valid pages.lastpage = pages.contentChildren[currentIndex].last + } function next() { diff --git a/electrum/gui/qml/components/wizard/WizardComponent.qml b/electrum/gui/qml/components/wizard/WizardComponent.qml index 798b7ad8d..3ee0fbc22 100644 --- a/electrum/gui/qml/components/wizard/WizardComponent.qml +++ b/electrum/gui/qml/components/wizard/WizardComponent.qml @@ -2,6 +2,7 @@ import QtQuick 2.0 Item { signal next + signal prev signal accept property var wizard_data : ({}) property bool valid diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 273ac8e03..9745bcd7c 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -29,6 +29,7 @@ from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails from .qechanneldetails import QEChannelDetails from .qeswaphelper import QESwapHelper +from .qewizard import QENewWalletWizard notification = None @@ -217,6 +218,7 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QERequestDetails, 'org.electrum', 1, 0, 'RequestDetails') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') + qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'NewWalletWizard', 'NewWalletWizard can only be used as property') self.engine = QQmlApplicationEngine(parent=self) @@ -254,6 +256,8 @@ class ElectrumQmlApplication(QGuiApplication): 'protocol_version': version.PROTOCOL_VERSION }) + self.plugins.load_plugin('trustedcoin') + qInstallMessageHandler(self.message_handler) # get notified whether root QML document loads or not diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index b3f9cd82c..d7a63b429 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -10,6 +10,7 @@ from electrum.logging import get_logger from electrum.slip39 import decode_mnemonic, Slip39Error from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI, get_asyncio_loop from electrum.transaction import tx_from_any +from electrum.mnemonic import is_any_2fa_seed_type from .qetypes import QEAmount @@ -69,7 +70,8 @@ class QEBitcoin(QObject): @pyqtSlot(str) @pyqtSlot(str,bool,bool) - @pyqtSlot(str,bool,bool,str,str,str) + @pyqtSlot(str,bool,bool,str) + @pyqtSlot(str,bool,bool,str,str) def verify_seed(self, seed, bip39=False, slip39=False, wallet_type='standard', language='en'): self._logger.debug('bip39 ' + str(bip39)) self._logger.debug('slip39 ' + str(slip39)) @@ -100,9 +102,10 @@ class QEBitcoin(QObject): self.validationMessage = 'SLIP39: %s' % str(e) seed_valid = False # for now - # cosigning seed - if wallet_type != 'standard' and seed_type not in ['standard', 'segwit']: - seed_type = '' + # check if seed matches wallet type + if wallet_type == '2fa' and not is_any_2fa_seed_type(seed_type): + seed_valid = False + elif wallet_type == 'standard' and seed_type not in ['old', 'standard', 'segwit']: seed_valid = False self.seedType = seed_type diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 7e5e2562c..e56c848c4 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -14,6 +14,7 @@ from .auth import AuthMixin, auth_protect from .qefx import QEFX from .qewallet import QEWallet from .qewalletdb import QEWalletDB +from .qewizard import QENewWalletWizard # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) @@ -121,16 +122,19 @@ class QEDaemon(AuthMixin, QObject): _loaded_wallets = QEWalletListModel() _available_wallets = None _current_wallet = None + _new_wallet_wizard = None _path = None _use_single_password = False _password = None - walletLoaded = pyqtSignal() - walletRequiresPassword = pyqtSignal() activeWalletsChanged = pyqtSignal() availableWalletsChanged = pyqtSignal() - walletOpenError = pyqtSignal([str], arguments=["error"]) fxChanged = pyqtSignal() + newWalletWizardChanged = pyqtSignal() + + walletLoaded = pyqtSignal() + walletRequiresPassword = pyqtSignal() + walletOpenError = pyqtSignal([str], arguments=["error"]) walletDeleteError = pyqtSignal([str,str], arguments=['code', 'message']) @pyqtSlot() @@ -283,3 +287,9 @@ class QEDaemon(AuthMixin, QObject): self.daemon.update_password_for_directory(old_password=self._password, new_password=password) self._password = password + @pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged) + def newWalletWizard(self): + if not self._new_wallet_wizard: + self._new_wallet_wizard = QENewWalletWizard(self) + + return self._new_wallet_wizard diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 333b31896..ec71a6082 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -304,6 +304,10 @@ class QEWallet(AuthMixin, QObject, QtEventListener): def canHaveLightning(self): return self.wallet.can_have_lightning() + @pyqtProperty(str, notify=dataChanged) + def walletType(self): + return self.wallet.wallet_type + @pyqtProperty(bool, notify=dataChanged) def hasSeed(self): return self.wallet.has_seed() diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py index 33a736117..52ff68c01 100644 --- a/electrum/gui/qml/qewalletdb.py +++ b/electrum/gui/qml/qewalletdb.py @@ -29,10 +29,8 @@ class QEWalletDB(QObject): requiresSplitChanged = pyqtSignal() splitFinished = pyqtSignal() readyChanged = pyqtSignal() - createError = pyqtSignal([str], arguments=["error"]) - createSuccess = pyqtSignal() invalidPassword = pyqtSignal() - + def reset(self): self._path = None self._needsPassword = False @@ -172,69 +170,3 @@ class QEWalletDB(QObject): self._ready = True self.readyChanged.emit() - @pyqtSlot('QJSValue',bool,str) - def create_storage(self, js_data, single_password_enabled, single_password): - self._logger.info('Creating wallet from wizard data') - data = js_data.toVariant() - self._logger.debug(str(data)) - - assert data['wallet_type'] == 'standard' # only standard wallets for now - - if single_password_enabled and single_password: - data['encrypt'] = True - data['password'] = single_password - - try: - path = os.path.join(os.path.dirname(self.daemon.config.get_wallet_path()), data['wallet_name']) - if os.path.exists(path): - raise Exception('file already exists at path') - storage = WalletStorage(path) - - if data['keystore_type'] in ['createseed', 'haveseed']: - if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-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': - self._logger.debug('creating keystore from bip39 seed') - root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) - 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) - else: - raise Exception('unsupported/unknown seed_type %s' % data['seed_type']) - elif data['keystore_type'] == 'masterkey': - k = keystore.from_master_key(data['master_key']) - has_xpub = isinstance(k, keystore.Xpub) - assert has_xpub - t1 = xpub_type(k.xpub) - if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: - raise Exception('wrong key type %s' % t1) - else: - raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) - - if data['encrypt']: - if k.may_have_password(): - k.update_password(None, data['password']) - storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) - - db = WalletDB('', manual_upgrades=False) - db.set_keystore_encryption(bool(data['password']) and data['encrypt']) - - db.put('wallet_type', data['wallet_type']) - if 'seed_type' in data: - db.put('seed_type', data['seed_type']) - db.put('keystore', k.dump()) - if k.can_have_deterministic_lightning_xprv(): - db.put('lightning_xprv', k.get_lightning_xprv(data['password'] if data['encrypt'] else None)) - - db.load_plugins() - db.write(storage) - - # minimally populate self after create - self._password = data['password'] - self.path = path - - self.createSuccess.emit() - except Exception as e: - self._logger.error(repr(e)) - self.createError.emit(str(e)) diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py new file mode 100644 index 000000000..6ada4787b --- /dev/null +++ b/electrum/gui/qml/qewizard.py @@ -0,0 +1,160 @@ +import os + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtQml import QQmlApplicationEngine + +from electrum.logging import get_logger +from electrum.gui.wizard import NewWalletWizard + +from electrum.storage import WalletStorage, StorageEncryptionVersion +from electrum.wallet_db import WalletDB +from electrum.bip32 import normalize_bip32_derivation, xpub_type +from electrum import keystore + +class QEAbstractWizard(QObject): + _logger = get_logger(__name__) + + def __init__(self, parent = None): + QObject.__init__(self, parent) + + @pyqtSlot(result=str) + def start_wizard(self): + self.start() + return self._current.view + + @pyqtSlot(str, result=str) + def viewToComponent(self, view): + return self.navmap[view]['gui'] + '.qml' + + @pyqtSlot('QJSValue', result='QVariant') + def submit(self, wizard_data): + wdata = wizard_data.toVariant() + self._logger.debug(str(wdata)) + view = self.resolve_next(self._current.view, wdata) + return { 'view': view.view, 'wizard_data': view.wizard_data } + + @pyqtSlot(result='QVariant') + def prev(self): + viewstate = self.resolve_prev() + return viewstate.wizard_data + + @pyqtSlot('QJSValue', result=bool) + def isLast(self, wizard_data): + wdata = wizard_data.toVariant() + return self.is_last_view(self._current.view, wdata) + + +class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): + + createError = pyqtSignal([str], arguments=["error"]) + createSuccess = pyqtSignal() + + def __init__(self, daemon, parent = None): + NewWalletWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, parent) + self._daemon = daemon + + # attach view names + self.navmap_merge({ + 'wallet_name': { 'gui': 'WCWalletName' }, + 'wallet_type': { 'gui': 'WCWalletType' }, + 'keystore_type': { 'gui': 'WCKeystoreType' }, + 'create_seed': { 'gui': 'WCCreateSeed' }, + 'confirm_seed': { 'gui': 'WCConfirmSeed' }, + 'have_seed': { 'gui': 'WCHaveSeed' }, + 'bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'have_master_key': { 'gui': 'WCHaveMasterKey' }, + 'wallet_password': { 'gui': 'WCWalletPassword' } + }) + + pathChanged = pyqtSignal() + @pyqtProperty(str, notify=pathChanged) + def path(self): + return self._path + + @path.setter + def path(self, path): + self._path = path + self.pathChanged.emit() + + def last_if_single_password(self, view, wizard_data): + return self._daemon.singlePasswordEnabled + + @pyqtSlot('QJSValue',bool,str) + def create_storage(self, js_data, single_password_enabled, single_password): + self._logger.info('Creating wallet from wizard data') + data = js_data.toVariant() + self._logger.debug(str(data)) + + # only standard and 2fa wallets for now + assert data['wallet_type'] in ['standard', '2fa'] + + if single_password_enabled and single_password: + data['encrypt'] = True + data['password'] = single_password + + try: + path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) + if os.path.exists(path): + raise Exception('file already exists at path') + storage = WalletStorage(path) + + if data['keystore_type'] in ['createseed', 'haveseed']: + if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-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': + self._logger.debug('creating keystore from bip39 seed') + root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + 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 + self._logger.debug('creating keystore from 2fa seed') + k = keystore.from_xprv(data['x1/']['xprv']) + else: + raise Exception('unsupported/unknown seed_type %s' % data['seed_type']) + elif data['keystore_type'] == 'masterkey': + k = keystore.from_master_key(data['master_key']) + has_xpub = isinstance(k, keystore.Xpub) + assert has_xpub + t1 = xpub_type(k.xpub) + if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: + raise Exception('wrong key type %s' % t1) + else: + raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) + + if data['encrypt']: + if k.may_have_password(): + k.update_password(None, data['password']) + storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + + db = WalletDB('', manual_upgrades=False) + db.set_keystore_encryption(bool(data['password']) and data['encrypt']) + + db.put('wallet_type', data['wallet_type']) + if 'seed_type' in data: + db.put('seed_type', data['seed_type']) + + if data['wallet_type'] == 'standard': + db.put('keystore', k.dump()) + elif data['wallet_type'] == '2fa': + db.put('x1/', k.dump()) + db.put('x2/', data['x2/']) + db.put('x3/', data['x3/']) + db.put('use_trustedcoin', True) + + if k.can_have_deterministic_lightning_xprv(): + db.put('lightning_xprv', k.get_lightning_xprv(data['password'] if data['encrypt'] else None)) + + db.load_plugins() + db.write(storage) + + # minimally populate self after create + self._password = data['password'] + self.path = path + + self.createSuccess.emit() + except Exception as e: + self._logger.error(repr(e)) + self.createError.emit(str(e)) diff --git a/electrum/plugins/trustedcoin/__init__.py b/electrum/plugins/trustedcoin/__init__.py index 81ec4f2ce..e04ed9dc8 100644 --- a/electrum/plugins/trustedcoin/__init__.py +++ b/electrum/plugins/trustedcoin/__init__.py @@ -8,4 +8,4 @@ description = ''.join([ ]) requires_wallet_type = ['2fa'] registers_wallet_type = '2fa' -available_for = ['qt', 'cmdline', 'kivy'] +available_for = ['qt', 'cmdline', 'kivy', 'qml'] diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py new file mode 100644 index 000000000..1b98cf66d --- /dev/null +++ b/electrum/plugins/trustedcoin/qml.py @@ -0,0 +1,332 @@ +import threading +import socket + +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 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) + +class Plugin(TrustedCoinPlugin): + + class QSignalObject(PluginQObject): + requestView = pyqtSignal([str], arguments=['component']) + + canSignWithoutServerChanged = pyqtSignal() + _canSignWithoutServer = False + termsAndConditionsChanged = pyqtSignal() + _termsAndConditions = '' + termsAndConditionsErrorChanged = pyqtSignal() + _termsAndConditionsError = '' + createRemoteKeyErrorChanged = pyqtSignal() + _createRemoteKeyError = '' + otpError = pyqtSignal() + otpSuccess = pyqtSignal() + disclaimerChanged = pyqtSignal() + keystoreChanged = pyqtSignal() + otpSecretChanged = pyqtSignal() + _otpSecret = '' + shortIdChanged = pyqtSignal() + _shortId = '' + + def __init__(self, plugin, parent): + super().__init__(plugin, parent) + + @pyqtSlot(result=str) + def settingsComponent(self): return '../../../plugins/trustedcoin/qml/Settings.qml' + + @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=termsAndConditionsChanged) + def termsAndConditions(self): + return self._termsAndConditions + + @pyqtProperty(str, notify=termsAndConditionsErrorChanged) + def termsAndConditionsError(self): + return self._termsAndConditionsError + + @pyqtProperty(str, notify=createRemoteKeyErrorChanged) + def createRemoteKeyError(self): + return self._createRemoteKeyError + + @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 = _('Error connecting to server') + self.termsAndConditionsErrorChanged.emit() + except Exception as e: + self._termsAndConditionsError = '%s: %s' % (_('Error'), repr(e)) + self.termsAndConditionsErrorChanged.emit() + else: + self._termsAndConditions = tos + self.termsAndConditionsChanged.emit() + 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): + xprv1, xpub1, 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): + self._createRemoteKeyError = _('Error creating key') + self.createRemoteKeyErrorChanged.emit() + except TrustedCoinException as e: + # if e.status_code == 409: TODO ? + # r = None + self._createRemoteKeyError = str(e) + self.createRemoteKeyErrorChanged.emit() + except (KeyError,TypeError) as e: # catch any assumptions + self._createRemoteKeyError = str(e) + self.createRemoteKeyErrorChanged.emit() + else: + if short_id != _id: + self._createRemoteKeyError = "unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id) + self.createRemoteKeyErrorChanged.emit() + return + if xpub3 != _xpub3: + self._createRemoteKeyError = "unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3) + self.createRemoteKeyErrorChanged.emit() + return + 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(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() + else: + self.plugin.logger.error(str(e)) + self._createRemoteKeyError = str(e) + self.createRemoteKeyErrorChanged.emit() + except Exception as e: + self.plugin.logger.error(str(e)) + self._createRemoteKeyError = str(e) + self.createRemoteKeyErrorChanged.emit() + 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) + t.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)}"') + #wallet.handler_2fa = HandlerTwoFactor(self, window) + 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.') + ]) + #action = lambda: window.show_message(msg) + #else: + #action = partial(self.settings_dialog, window) + #button = StatusBarButton(read_QIcon("trustedcoin-status.png"), + #_("TrustedCoin"), action) + #window.statusBar().addPermanentWidget(button) + self.start_request_thread(wallet) + + @hook + def init_qml(self, gui: 'ElectrumGui'): + self.logger.debug(f'init_qml hook called, gui={str(type(gui))}') + self._app = gui.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() + + 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': self.on_choose_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_tos_email' + }, + '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': wizard.last_if_single_password + } + } + 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): + 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,xpub2,xpub3,short_id) + + def on_accept_otp_secret(self, wizard_data): + self.logger.debug('on accept otp: ' + repr(wizard_data)) + + xprv1,xpub1,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() + # wizard_data['use_trustedcoin'] = True + + + + # wizard + def request_otp_dialog(self, wizard, short_id, otp_secret, xpub3): + f = lambda otp, reset: self.check_otp(wizard, short_id, otp_secret, xpub3, otp, reset) + wizard.otp_dialog(otp_secret=otp_secret, run_next=f) + + # regular wallet prompt function + 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 + self.wallet = wallet + self.tx = tx + self.so.requestView.emit('../../../../plugins/trustedcoin/qml/OTP.qml') + + def on_otp(self, otp): + try: + self.wallet.on_otp(self.tx, otp) + except TrustedCoinException as e: + if e.status_code == 400: # invalid OTP +# Clock.schedule_once(lambda dt: + self.on_failure(_('Invalid one-time password.')) +# ) + else: +# Clock.schedule_once(lambda dt, bound_e=e: + self.on_failure(_('Error') + ':\n' + str(bound_e)) +# ) + except Exception as e: +# Clock.schedule_once(lambda dt, bound_e=e: + self.on_failure(_('Error') + ':\n' + str(bound_e)) +# ) + else: + self.on_success(tx) diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml new file mode 100644 index 000000000..8665dee27 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml @@ -0,0 +1,38 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: keystoregroup.checkedButton !== null + + onAccept: { + wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype + } + + ButtonGroup { + id: keystoregroup + } + + ColumnLayout { + width: parent.width + Label { + text: qsTr('Do you want to create a new seed, or restore a wallet using an existing seed?') + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'createseed' + checked: true + text: qsTr('Create a new seed') + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'haveseed' + text: qsTr('I already have a seed') + } + } +} + diff --git a/electrum/plugins/trustedcoin/qml/Disclaimer.qml b/electrum/plugins/trustedcoin/qml/Disclaimer.qml new file mode 100644 index 000000000..b1a1f2884 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/Disclaimer.qml @@ -0,0 +1,27 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: true + + property QtObject plugin + + ColumnLayout { + width: parent.width + + Label { + Layout.preferredWidth: parent.width + text: plugin ? plugin.disclaimer : '' + wrapMode: Text.Wrap + } + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + } +} diff --git a/electrum/plugins/trustedcoin/qml/Settings.qml b/electrum/plugins/trustedcoin/qml/Settings.qml new file mode 100644 index 000000000..c8b7560f4 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/Settings.qml @@ -0,0 +1,46 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +//import "controls" + +Item { + width: parent.width + height: rootLayout.height + + property QtObject plugin + + RowLayout { + id: rootLayout + Button { + text: 'Force upload' + enabled: !plugin.busy + onClicked: plugin.upload() + } + Button { + text: 'Force download' + enabled: !plugin.busy + onClicked: plugin.download() + } + } + + Connections { + target: plugin + function onUploadSuccess() { + console.log('upload success') + } + function onUploadFailed() { + console.log('upload failed') + } + function onDownloadSuccess() { + console.log('download success') + } + function onDownloadFailed() { + console.log('download failed') + } + } + +} diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml new file mode 100644 index 000000000..da2e2eb0b --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml @@ -0,0 +1,101 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" + +WizardComponent { + valid: otpVerified + + property QtObject plugin + + property bool otpVerified: false + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Authenticator secret') + } + + InfoTextArea { + iconStyle: InfoTextArea.IconStyle.Error + visible: plugin ? plugin.createRemoteKeyError : false + text: plugin ? plugin.createRemoteKeyError : '' + } + + QRImage { + Layout.alignment: Qt.AlignHCenter + qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name'] + + '?secret=' + plugin.otpSecret + '&digits=6') + render: plugin ? plugin.otpSecret : false + } + + TextHighlightPane { + Layout.alignment: Qt.AlignHCenter + visible: plugin.otpSecret + Label { + text: plugin.otpSecret + font.family: FixedFont + font.bold: true + } + } + + Label { + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('Enter or scan into authenticator app. Then authenticate below') + visible: plugin.otpSecret && !otpVerified + } + + TextField { + id: otp_auth + Layout.alignment: Qt.AlignHCenter + focus: true + visible: plugin.otpSecret && !otpVerified + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + onTextChanged: { + if (text.length >= 6) { + plugin.checkOtp(plugin.shortId, otp_auth.text) + text = '' + } + } + } + + Image { + Layout.alignment: Qt.AlignHCenter + source: '../../../gui/icons/confirmed.png' + visible: otpVerified + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + } + + BusyIndicator { + anchors.centerIn: parent + visible: plugin ? plugin.busy : false + running: visible + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + plugin.createKeystore(wizard_data['2fa_email']) + otp_auth.forceActiveFocus() + } + + Connections { + target: plugin + function onOtpError() { + console.log('OTP verify error') + // TODO: show error in UI + } + function onOtpSuccess() { + console.log('OTP verify success') + otpVerified = true + } + } +} + diff --git a/electrum/plugins/trustedcoin/qml/Terms.qml b/electrum/plugins/trustedcoin/qml/Terms.qml new file mode 100644 index 000000000..d0e30ffa6 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/Terms.qml @@ -0,0 +1,67 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" + +WizardComponent { + valid: !plugin ? false + : email.text.length > 0 // TODO: validate email address + && plugin.termsAndConditions + + property QtObject plugin + + onAccept: { + wizard_data['2fa_email'] = email.text + } + + ColumnLayout { + anchors.fill: parent + + Label { text: qsTr('Terms and conditions') } + + TextHighlightPane { + Layout.fillWidth: true + Layout.fillHeight: true + rightPadding: 0 + + Flickable { + anchors.fill: parent + contentHeight: termsText.height + clip: true + boundsBehavior: Flickable.StopAtBounds + + Label { + id: termsText + width: parent.width + rightPadding: constants.paddingSmall + wrapMode: Text.Wrap + text: plugin ? plugin.termsAndConditions : '' + } + ScrollIndicator.vertical: ScrollIndicator { } + } + + BusyIndicator { + anchors.centerIn: parent + visible: plugin ? plugin.busy : false + running: visible + } + } + + Label { text: qsTr('Email') } + + TextField { + id: email + Layout.fillWidth: true + placeholderText: qsTr('Enter your email address') + } + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + plugin.fetchTermsAndConditions() + } +} diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index f5dfdb343..34a1be886 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -69,7 +69,7 @@ def get_billing_xpub(): return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" -DISCLAIMER = [ +DESKTOP_DISCLAIMER = [ _("Two-factor authentication is a service provided by TrustedCoin. " "It uses a multi-signature wallet, where you own 2 of 3 keys. " "The third key is stored on a remote server that signs transactions on " @@ -86,8 +86,9 @@ DISCLAIMER = [ "To be safe from malware, you may want to do this on an offline " "computer, and move your wallet later to an online computer."), ] +DISCLAIMER = DESKTOP_DISCLAIMER -KIVY_DISCLAIMER = [ +MOBILE_DISCLAIMER = [ _("Two-factor authentication is a service provided by TrustedCoin. " "To use it, you must have a separate device with Google Authenticator."), _("This service uses a multi-signature wallet, where you own 2 of 3 keys. " @@ -98,6 +99,8 @@ KIVY_DISCLAIMER = [ "your funds at any time and at no cost, without the remote server, by " "using the 'restore wallet' option with your wallet seed."), ] +KIVY_DISCLAIMER = MOBILE_DISCLAIMER + RESTORE_MSG = _("Enter the seed for your 2-factor wallet:") class TrustedCoinException(Exception):