From a4195267ff8cd67c91417970eafa48ace13a2afd Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 22 Sep 2022 12:40:36 +0200 Subject: [PATCH 01/15] new wizard approach --- electrum/gui/wizard.py | 199 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 electrum/gui/wizard.py diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py new file mode 100644 index 000000000..a717dd3f2 --- /dev/null +++ b/electrum/gui/wizard.py @@ -0,0 +1,199 @@ +import copy + +from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union + +from electrum.logging import get_logger + +class WizardViewState(NamedTuple): + view: str + wizard_data: Dict[str, Any] + params: Dict[str, Any] + +class AbstractWizard: + # serve as a base for all UIs, so no qt + # encapsulate wizard state + # encapsulate navigation decisions, UI agnostic + # encapsulate stack, go backwards + # allow extend/override flow in subclasses e.g. + # - override: replace 'next' value to own fn + # - extend: add new keys to navmap, wire up flow by override + + _logger = get_logger(__name__) + + navmap = {} + + _current = WizardViewState(None, {}, {}) + _stack = [] # type: List[WizardViewState] + + def navmap_merge(self, additional_navmap): + # NOTE: only merges one level deep. Deeper dict levels will overwrite + for k,v in additional_navmap.items(): + if k in self.navmap: + self.navmap[k].update(v) + else: + self.navmap[k] = v + + # from current view and wizard_data, resolve the new view + # returns WizardViewState tuple (view name, wizard_data, view params) + # view name is the string id of the view in the nav map + # wizard data is the (stacked) wizard data dict containing user input and choices + # view params are transient, meant for extra configuration of a view (e.g. info + # msg in a generic choice dialog) + # exception: stay on this view + def resolve_next(self, view, wizard_data): + assert view + self._logger.debug(f'view={view}') + assert view in self.navmap + + nav = self.navmap[view] + + if 'accept' in nav: + # allow python scope to append to wizard_data before + # adding to stack or finishing + if callable(nav['accept']): + nav['accept'](wizard_data) + else: + self._logger.error(f'accept handler for view {view} not callable') + + if not 'next' in nav: + # finished + self.finished(wizard_data) + return (None, wizard_data, {}) + + nexteval = nav['next'] + # simple string based next view + if isinstance(nexteval, str): + new_view = WizardViewState(nexteval, wizard_data, {}) + else: + # handler fn based next view + nv = nexteval(wizard_data) + self._logger.debug(repr(nv)) + + # append wizard_data and params if not returned + if isinstance(nv, str): + new_view = WizardViewState(nv, wizard_data, {}) + elif len(nv) == 1: + new_view = WizardViewState(nv[0], wizard_data, {}) + elif len(nv) == 2: + new_view = WizardViewState(nv[0], nv[1], {}) + else: + new_view = nv + + self._stack.append(copy.deepcopy(self._current)) + self._current = new_view + + self._logger.debug(f'resolve_next view is {self._current.view}') + self._logger.debug('stack:' + repr(self._stack)) + + return new_view + + def resolve_prev(self): + prev_view = self._stack.pop() + self._logger.debug(f'resolve_prev view is {prev_view}') + self._logger.debug('stack:' + repr(self._stack)) + self._current = prev_view + return prev_view + + # check if this view is the final view + def is_last_view(self, view, wizard_data): + assert view + assert view in self.navmap + + nav = self.navmap[view] + + if not 'last' in nav: + return False + + lastnav = nav['last'] + # bool literal + if isinstance(lastnav, bool): + return lastnav + elif callable(lastnav): + # handler fn based + l = lastnav(view, wizard_data) + self._logger.debug(f'view "{view}" last: {l}') + return l + else: + raise Exception('last handler for view {view} is not callable nor a bool literal') + + def finished(self, wizard_data): + self._logger.debug('finished.') + + def reset(self): + self.stack = [] + self._current = WizardViewState(None, {}, {}) + +class NewWalletWizard(AbstractWizard): + + _logger = get_logger(__name__) + + def __init__(self, daemon): + self.navmap = { + 'wallet_name': { + 'next': 'wallet_type' + }, + 'wallet_type': { + 'next': self.on_wallet_type + }, + 'keystore_type': { + 'next': self.on_keystore_type + }, + 'create_seed': { + 'next': 'confirm_seed' + }, + 'confirm_seed': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'have_seed': { + 'next': self.on_have_seed, + 'last': self.last_if_single_password_and_not_bip39 + }, + 'bip39_refine': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'have_master_key': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, + 'wallet_password': { + 'last': True + } + } + self._daemon = daemon + + def start(self, initial_data = {}): + self.reset() + self._current = WizardViewState('wallet_name', initial_data, {}) + return self._current + + def last_if_single_password(self, view, wizard_data): + return False # TODO: self._daemon.config.get('single_password') + + def last_if_single_password_and_not_bip39(self, view, wizard_data): + return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_type'] == 'bip39' + + def on_wallet_type(self, wizard_data): + if wizard_data['wallet_type'] == '2fa': + return 'trustedcoin_start' + + return 'keystore_type' + + def on_keystore_type(self, wizard_data): + t = wizard_data['keystore_type'] + return { + 'createseed': 'create_seed', + 'haveseed': 'have_seed', + 'masterkey': 'have_master_key' + }.get(t) + + def on_have_seed(self, wizard_data): + if (wizard_data['seed_type'] == 'bip39'): + return 'bip39_refine' + else: + return 'wallet_password' + + def finished(self, wizard_data): + self._logger.debug('finished') + # override From 43bac2edff3fde3d86bcb1b8b6629aa4e358c031 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 22 Sep 2022 12:43:51 +0200 Subject: [PATCH 02/15] qml: use new wizard approach in qml and also implement 2FA/trustedcoin --- electrum/gui/qml/__init__.py | 4 - .../gui/qml/components/NewWalletWizard.qml | 138 +------- electrum/gui/qml/components/Wallets.qml | 7 +- .../qml/components/wizard/WCCreateSeed.qml | 8 +- .../gui/qml/components/wizard/WCHaveSeed.qml | 16 +- .../qml/components/wizard/WCWalletType.qml | 6 +- electrum/gui/qml/components/wizard/Wizard.qml | 26 +- .../qml/components/wizard/WizardComponent.qml | 1 + electrum/gui/qml/qeapp.py | 4 + electrum/gui/qml/qebitcoin.py | 11 +- electrum/gui/qml/qedaemon.py | 16 +- electrum/gui/qml/qewallet.py | 4 + electrum/gui/qml/qewalletdb.py | 70 +--- electrum/gui/qml/qewizard.py | 160 +++++++++ electrum/plugins/trustedcoin/__init__.py | 2 +- electrum/plugins/trustedcoin/qml.py | 332 ++++++++++++++++++ .../plugins/trustedcoin/qml/ChooseSeed.qml | 38 ++ .../plugins/trustedcoin/qml/Disclaimer.qml | 27 ++ electrum/plugins/trustedcoin/qml/Settings.qml | 46 +++ .../trustedcoin/qml/ShowConfirmOTP.qml | 101 ++++++ electrum/plugins/trustedcoin/qml/Terms.qml | 67 ++++ electrum/plugins/trustedcoin/trustedcoin.py | 7 +- 22 files changed, 874 insertions(+), 217 deletions(-) create mode 100644 electrum/gui/qml/qewizard.py create mode 100644 electrum/plugins/trustedcoin/qml.py create mode 100644 electrum/plugins/trustedcoin/qml/ChooseSeed.qml create mode 100644 electrum/plugins/trustedcoin/qml/Disclaimer.qml create mode 100644 electrum/plugins/trustedcoin/qml/Settings.qml create mode 100644 electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml create mode 100644 electrum/plugins/trustedcoin/qml/Terms.qml 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): From e0a3efe5b520099394059f680f1a73f521843679 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 22 Sep 2022 14:41:29 +0200 Subject: [PATCH 03/15] move wallet creation from wizard to ui agnostic NewWalletWizard --- .../gui/qml/components/NewWalletWizard.qml | 2 +- electrum/gui/qml/qewizard.py | 70 ++----------------- electrum/gui/wizard.py | 64 +++++++++++++++++ 3 files changed, 70 insertions(+), 66 deletions(-) diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index b2ceac550..1f47eb7af 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -26,7 +26,7 @@ Wizard { onAccepted: { console.log('Finished new wallet wizard') - wiz.create_storage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) + wiz.createStorage(wizard_data, Daemon.singlePasswordEnabled, Daemon.singlePassword) } Connections { diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index 6ada4787b..f9749d3d3 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -6,11 +6,6 @@ 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__) @@ -80,75 +75,20 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): 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): + @pyqtSlot('QJSValue', bool, str) + def createStorage(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 + path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) + 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) + self.create_storage(path, data) # minimally populate self after create self._password = data['password'] diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py index a717dd3f2..01dbdfbdb 100644 --- a/electrum/gui/wizard.py +++ b/electrum/gui/wizard.py @@ -1,8 +1,13 @@ import copy +import os from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional, Union from electrum.logging import get_logger +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 WizardViewState(NamedTuple): view: str @@ -197,3 +202,62 @@ class NewWalletWizard(AbstractWizard): def finished(self, wizard_data): self._logger.debug('finished') # override + + def create_storage(self, path, data): + # only standard and 2fa wallets for now + assert data['wallet_type'] in ['standard', '2fa'] + + 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) From e579f5b8a5b01954427270eb06d2db5621e80515 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 29 Sep 2022 13:47:37 +0200 Subject: [PATCH 04/15] properly remember wallet password and pass to tx signing call --- electrum/gui/qml/qedaemon.py | 9 ++++++--- electrum/gui/qml/qewallet.py | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index e56c848c4..dfd21e32f 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -161,7 +161,9 @@ class QEDaemon(AuthMixin, QObject): if not password: password = self._password - if self._path not in self.daemon._wallets: + wallet_already_open = self._path in self.daemon._wallets + + if not wallet_already_open: # pre-checks, let walletdb trigger any necessary user interactions self._walletdb.path = self._path self._walletdb.password = password @@ -172,9 +174,10 @@ class QEDaemon(AuthMixin, QObject): try: wallet = self.daemon.load_wallet(self._path, password) if wallet != None: - self._loaded_wallets.add_wallet(wallet_path=self._path, wallet=wallet) self._current_wallet = QEWallet.getInstanceFor(wallet) - self._current_wallet.password = password + if not wallet_already_open: + self._loaded_wallets.add_wallet(wallet_path=self._path, wallet=wallet) + self._current_wallet.password = password self.walletLoaded.emit() if self.daemon.config.get('single_password'): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index ec71a6082..8c38a326e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -554,7 +554,10 @@ class QEWallet(AuthMixin, QObject, QtEventListener): addr = None if self.wallet.config.get('bolt11_fallback', True): addr = self.wallet.get_unused_address() - # if addr is None, we ran out of addresses. for lightning enabled wallets, ignore for now + # if addr is None, we ran out of addresses + if addr is None: + # TODO: remove oldest unpaid request having a fallback address and try again + pass key = self.wallet.create_request(None, None, default_expiry, addr) else: key, addr = self.create_bitcoin_request(None, None, default_expiry, ignore_gap) From 20c95b26ea16d9ffda9548758f95ee0bf5c96783 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 29 Sep 2022 18:05:06 +0200 Subject: [PATCH 05/15] wip otp on pay --- electrum/gui/qml/components/OtpDialog.qml | 53 +++++++++++++++++++ .../gui/qml/components/WalletMainView.qml | 23 ++++++++ electrum/gui/qml/qewallet.py | 25 +++++++++ electrum/plugins/trustedcoin/qml.py | 27 +++++----- 4 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 electrum/gui/qml/components/OtpDialog.qml diff --git a/electrum/gui/qml/components/OtpDialog.qml b/electrum/gui/qml/components/OtpDialog.qml new file mode 100644 index 000000000..70f9c6f65 --- /dev/null +++ b/electrum/gui/qml/components/OtpDialog.qml @@ -0,0 +1,53 @@ +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" + +ElDialog { + id: dialog + + title: qsTr('OTP auth') + + property string otpauth + + // property var lnurlData + // property InvoiceParser invoiceParser + // property alias lnurlData: dialog.invoiceParser.lnurlData + + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + columns: 2 + implicitWidth: parent.width + + Label { + text: qsTr('code') + } + + TextField { + id: otpEdit + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Proceed') + onClicked: { + // dialog.close() + otpauth = otpEdit.text + dialog.accept() + } + } + + } +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index e7eb1455b..21523695b 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -256,6 +256,19 @@ Item { } } + Connections { + target: Daemon.currentWallet + function onOtpRequested() { + console.log('OTP requested') + var dialog = otpDialog.createObject(mainView) + dialog.accepted.connect(function() { + console.log('accepted ' + dialog.otpauth) + Daemon.currentWallet.finish_otp(dialog.otpauth) + }) + dialog.open() + } + } + Component { id: sendDialog SendDialog { @@ -304,5 +317,15 @@ Item { onClosed: destroy() } } + + Component { + id: otpDialog + OtpDialog { + width: parent.width * 0.9 + anchors.centerIn: parent + + onClosed: destroy() + } + } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 8c38a326e..9080f9e84 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -3,6 +3,7 @@ import queue import threading import time from typing import Optional, TYPE_CHECKING +from functools import partial from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer @@ -13,6 +14,7 @@ from electrum.logging import get_logger from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.transaction import PartialTxOutput from electrum.util import (parse_max_spend, InvalidPassword, event_listener) +from electrum.plugin import run_hook from .auth import AuthMixin, auth_protect from .qeaddresslistmodel import QEAddressListModel @@ -63,6 +65,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): #broadcastSucceeded = pyqtSignal([str], arguments=['txid']) broadcastFailed = pyqtSignal([str,str,str], arguments=['txid','code','reason']) labelsUpdated = pyqtSignal() + otpRequested = pyqtSignal() _network_signal = pyqtSignal(str, object) @@ -423,6 +426,16 @@ class QEWallet(AuthMixin, QObject, QtEventListener): @auth_protect def sign(self, tx, *, broadcast: bool = False): + sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast), None) + if sign_hook: + self.do_sign(tx, False) + self._logger.debug('plugin needs to sign tx too') + sign_hook(tx) + return + + self.do_sign(tx, broadcast) + + def do_sign(self, tx, broadcast): tx = self.wallet.sign_transaction(tx, self.password) if tx is None: @@ -441,6 +454,18 @@ class QEWallet(AuthMixin, QObject, QtEventListener): if broadcast: self.broadcast(tx) + def on_sign_complete(self, broadcast, tx): + if broadcast: + self.broadcast(tx) + + def request_otp(self, on_submit): + self._otp_on_submit = on_submit + self.otpRequested.emit() + + @pyqtSlot(str) + def finish_otp(self, otp): + self._otp_on_submit(otp) + def broadcast(self, tx): assert tx.is_complete() diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 1b98cf66d..7b7204dcc 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -6,6 +6,7 @@ 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.util import UserFacingException from electrum import keystore from electrum.gui.qml.qewallet import QEWallet @@ -18,8 +19,6 @@ from .trustedcoin import (TrustedCoinPlugin, server, ErrorConnectingServer, class Plugin(TrustedCoinPlugin): class QSignalObject(PluginQObject): - requestView = pyqtSignal([str], arguments=['component']) - canSignWithoutServerChanged = pyqtSignal() _canSignWithoutServer = False termsAndConditionsChanged = pyqtSignal() @@ -37,6 +36,8 @@ class Plugin(TrustedCoinPlugin): shortIdChanged = pyqtSignal() _shortId = '' + requestOtp = pyqtSignal() + def __init__(self, plugin, parent): super().__init__(plugin, parent) @@ -307,26 +308,24 @@ class Plugin(TrustedCoinPlugin): 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.on_failure = on_failure if on_failure else lambda x: self.logger.error(x) self.wallet = wallet self.tx = tx - self.so.requestView.emit('../../../../plugins/trustedcoin/qml/OTP.qml') + qewallet = QEWallet.getInstanceFor(wallet) + qewallet.request_otp(self.on_otp) def on_otp(self, otp): + 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 -# Clock.schedule_once(lambda dt: - self.on_failure(_('Invalid one-time password.')) -# ) + self.on_failure(_('Invalid one-time password.')) else: -# Clock.schedule_once(lambda dt, bound_e=e: - self.on_failure(_('Error') + ':\n' + str(bound_e)) -# ) + self.on_failure(_('Error') + ':\n' + str(e)) except Exception as e: -# Clock.schedule_once(lambda dt, bound_e=e: - self.on_failure(_('Error') + ':\n' + str(bound_e)) -# ) + self.on_failure(_('Error') + ':\n' + str(e)) else: - self.on_success(tx) + self.on_success(self.tx) From acb88f21c12f8d1ce13f872b5ea75639752f5d50 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 30 Sep 2022 15:40:05 +0200 Subject: [PATCH 06/15] implement success and failure paths, keep dialog over multiple tries --- electrum/gui/qml/components/OtpDialog.qml | 63 +++++++++++++++---- .../gui/qml/components/WalletMainView.qml | 2 +- electrum/gui/qml/qewallet.py | 12 +++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/electrum/gui/qml/components/OtpDialog.qml b/electrum/gui/qml/components/OtpDialog.qml index 70f9c6f65..e971d4c24 100644 --- a/electrum/gui/qml/components/OtpDialog.qml +++ b/electrum/gui/qml/components/OtpDialog.qml @@ -10,13 +10,13 @@ import "controls" ElDialog { id: dialog - title: qsTr('OTP auth') + title: qsTr('Trustedcoin') + iconSource: '../../../icons/trustedcoin-status.png' property string otpauth - // property var lnurlData - // property InvoiceParser invoiceParser - // property alias lnurlData: dialog.invoiceParser.lnurlData + property bool _waiting: false + property string _otpError standardButtons: Dialog.Cancel @@ -26,28 +26,67 @@ ElDialog { color: "#aa000000" } - GridLayout { - columns: 2 - implicitWidth: parent.width + focus: true + + ColumnLayout { + width: parent.width Label { - text: qsTr('code') + text: qsTr('Enter Authenticator code') + font.pixelSize: constants.fontSizeLarge + Layout.alignment: Qt.AlignHCenter } TextField { id: otpEdit + Layout.preferredWidth: fontMetrics.advanceWidth(passwordCharacter) * 6 + Layout.alignment: Qt.AlignHCenter + font.pixelSize: constants.fontSizeXXLarge + maximumLength: 6 + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly + echoMode: TextInput.Password + focus: true + onTextChanged: { + if (activeFocus) + _otpError = '' + } + } + + Label { + opacity: _otpError ? 1 : 0 + text: _otpError + color: constants.colorError + Layout.alignment: Qt.AlignHCenter } Button { Layout.columnSpan: 2 Layout.alignment: Qt.AlignHCenter - text: qsTr('Proceed') + text: qsTr('Submit') + enabled: !_waiting onClicked: { - // dialog.close() - otpauth = otpEdit.text - dialog.accept() + _waiting = true + Daemon.currentWallet.submitOtp(otpEdit.text) } } + } + + Connections { + target: Daemon.currentWallet + function onOtpSuccess() { + _waiting = false + otpauth = otpEdit.text + dialog.accept() + } + function onOtpFailed(code, message) { + _waiting = false + _otpError = message + otpEdit.text = '' + } + } + FontMetrics { + id: fontMetrics + font: otpEdit.font } } diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 21523695b..1ab844005 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -321,7 +321,7 @@ Item { Component { id: otpDialog OtpDialog { - width: parent.width * 0.9 + width: parent.width * 2/3 anchors.centerIn: parent onClosed: destroy() diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 9080f9e84..83aa4054a 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -66,6 +66,8 @@ class QEWallet(AuthMixin, QObject, QtEventListener): broadcastFailed = pyqtSignal([str,str,str], arguments=['txid','code','reason']) labelsUpdated = pyqtSignal() otpRequested = pyqtSignal() + otpSuccess = pyqtSignal() + otpFailed = pyqtSignal([str,str], arguments=['code','message']) _network_signal = pyqtSignal(str, object) @@ -426,7 +428,8 @@ class QEWallet(AuthMixin, QObject, QtEventListener): @auth_protect def sign(self, tx, *, broadcast: bool = False): - sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast), None) + sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast), + self.on_sign_failed) if sign_hook: self.do_sign(tx, False) self._logger.debug('plugin needs to sign tx too') @@ -454,16 +457,21 @@ class QEWallet(AuthMixin, QObject, QtEventListener): if broadcast: self.broadcast(tx) + # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok def on_sign_complete(self, broadcast, tx): + self.otpSuccess.emit() if broadcast: self.broadcast(tx) + def on_sign_failed(self, error): + self.otpFailed.emit('error', error) + def request_otp(self, on_submit): self._otp_on_submit = on_submit self.otpRequested.emit() @pyqtSlot(str) - def finish_otp(self, otp): + def submitOtp(self, otp): self._otp_on_submit(otp) def broadcast(self, tx): From 255f5085745d23c2f93a6a213a2def1a81ddef49 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 30 Sep 2022 15:47:18 +0200 Subject: [PATCH 07/15] remove unused, add type checking imports --- electrum/plugins/trustedcoin/qml.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 7b7204dcc..364178248 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,5 +1,6 @@ import threading import socket +from typing import TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot @@ -16,6 +17,10 @@ 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 ElectrumGui + from electrum.wallet import Abstract_Wallet + class Plugin(TrustedCoinPlugin): class QSignalObject(PluginQObject): @@ -297,14 +302,8 @@ class Plugin(TrustedCoinPlugin): wizard_data['x3/'] = k3.dump() # wizard_data['use_trustedcoin'] = True + # regular wallet prompt functions - - # 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 From 6ea3a16cc84d9bce178ea4b7bf7bac2b82a221ba Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 4 Oct 2022 12:00:50 +0200 Subject: [PATCH 08/15] add wallet type imported addresses/private keys to wizard --- electrum/gui/qml/components/Wallets.qml | 7 +- .../gui/qml/components/wizard/WCImport.qml | 102 ++++++++++++++++++ .../qml/components/wizard/WCWalletType.qml | 3 +- electrum/gui/qml/qeaddresslistmodel.py | 4 +- electrum/gui/qml/qebitcoin.py | 9 ++ electrum/gui/qml/qewallet.py | 5 + electrum/gui/qml/qewizard.py | 1 + electrum/gui/wizard.py | 42 ++++++-- 8 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 electrum/gui/qml/components/wizard/WCImport.qml diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml index 0a2100ada..583c3422d 100644 --- a/electrum/gui/qml/components/Wallets.qml +++ b/electrum/gui/qml/components/Wallets.qml @@ -153,9 +153,14 @@ Pane { Label { text: 'has Seed'; color: Material.accentColor } Label { text: Daemon.currentWallet.hasSeed } - Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } + Label { + visible: Daemon.currentWallet.masterPubkey + Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor + } TextHighlightPane { + visible: Daemon.currentWallet.masterPubkey + Layout.columnSpan: 4 Layout.fillWidth: true padding: 0 diff --git a/electrum/gui/qml/components/wizard/WCImport.qml b/electrum/gui/qml/components/wizard/WCImport.qml new file mode 100644 index 000000000..7f479f9ee --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCImport.qml @@ -0,0 +1,102 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../controls" + +WizardComponent { + id: root + + valid: false + + onAccept: { + if (bitcoin.isAddressList(import_ta.text)) { + wizard_data['address_list'] = import_ta.text + } else if (bitcoin.isPrivateKeyList(import_ta.text)) { + wizard_data['private_key_list'] = import_ta.text + } + } + + function verify(text) { + return bitcoin.isAddressList(text) || bitcoin.isPrivateKeyList(text) + } + + ColumnLayout { + width: parent.width + + Label { text: qsTr('Import Bitcoin Addresses') } + + InfoTextArea { + text: qsTr('Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.') + } + + RowLayout { + TextArea { + id: import_ta + Layout.fillWidth: true + Layout.minimumHeight: 80 + focus: true + wrapMode: TextEdit.WrapAnywhere + onTextChanged: valid = verify(text) + } + ColumnLayout { + Layout.alignment: Qt.AlignTop + ToolButton { + icon.source: '../../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + if (verify(AppController.clipboardToText())) { + if (import_ta.text != '') + import_ta.text = import_ta.text + '\n' + import_ta.text = import_ta.text + AppController.clipboardToText() + } + } + } + ToolButton { + icon.source: '../../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var scan = qrscan.createObject(root) + scan.onFound.connect(function() { + if (verify(scan.scanData)) { + if (import_ta.text != '') + import_ta.text = import_ta.text + ',\n' + import_ta.text = import_ta.text + scan.scanData + } + scan.destroy() + }) + } + } + } + } + } + + Component { + id: qrscan + QRScan { + width: root.width + height: root.height + + ToolButton { + icon.source: '../../../icons/closebutton.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + anchors.right: parent.right + anchors.top: parent.top + onClicked: { + parent.destroy() + } + } + } + } + + Bitcoin { + id: bitcoin + } + +} diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml index 8a55a0a29..1c0ee0c7a 100644 --- a/electrum/gui/qml/components/wizard/WCWalletType.qml +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -38,9 +38,8 @@ WizardComponent { text: qsTr('Multi-signature wallet') } RadioButton { - enabled: false ButtonGroup.group: wallettypegroup - property string wallettype: 'import' + property string wallettype: 'imported' text: qsTr('Import Bitcoin addresses or private keys') } } diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index c82802fbc..1a8881bad 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -65,7 +65,7 @@ class QEAddressListModel(QAbstractListModel): def init_model(self): r_addresses = self.wallet.get_receiving_addresses() c_addresses = self.wallet.get_change_addresses() - n_addresses = len(r_addresses) + len(c_addresses) + n_addresses = len(r_addresses) + len(c_addresses) if self.wallet.use_change else 0 def insert_row(atype, alist, address, iaddr): item = self.addr_to_model(address) @@ -80,7 +80,7 @@ class QEAddressListModel(QAbstractListModel): insert_row('receive', self.receive_addresses, address, i) i = i + 1 i = 0 - for address in c_addresses: + for address in c_addresses if self.wallet.use_change else []: insert_row('change', self.change_addresses, address, i) i = i + 1 self.endInsertRows() diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index d7a63b429..4d37d1206 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -164,3 +164,12 @@ class QEBitcoin(QObject): return True except: return False + + @pyqtSlot(str, result=bool) + def isAddressList(self, csv: str): + return keystore.is_address_list(csv) + + @pyqtSlot(str, result=bool) + def isPrivateKeyList(self, csv: str): + return keystore.is_private_key_list(csv) + diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 83aa4054a..3549c2c9e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -319,6 +319,8 @@ class QEWallet(AuthMixin, QObject, QtEventListener): @pyqtProperty(str, notify=dataChanged) def txinType(self): + if self.wallet.wallet_type == 'imported': + return self.wallet.txin_type return self.wallet.get_txin_type(self.wallet.dummy_address()) @pyqtProperty(bool, notify=dataChanged) @@ -342,6 +344,9 @@ class QEWallet(AuthMixin, QObject, QtEventListener): keystores = self.wallet.get_keystores() if len(keystores) > 1: self._logger.debug('multiple keystores not supported yet') + if len(keystores) == 0: + self._logger.debug('no keystore') + return '' return keystores[0].get_derivation_prefix() @pyqtProperty(str, notify=dataChanged) diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index f9749d3d3..0cb5b8a43 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -59,6 +59,7 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): 'have_seed': { 'gui': 'WCHaveSeed' }, 'bip39_refine': { 'gui': 'WCBIP39Refine' }, 'have_master_key': { 'gui': 'WCHaveMasterKey' }, + 'imported': { 'gui': 'WCImport' }, 'wallet_password': { 'gui': 'WCWalletPassword' } }) diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py index 01dbdfbdb..869a94ef3 100644 --- a/electrum/gui/wizard.py +++ b/electrum/gui/wizard.py @@ -8,6 +8,7 @@ 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 +from electrum import bitcoin class WizardViewState(NamedTuple): view: str @@ -162,6 +163,10 @@ class NewWalletWizard(AbstractWizard): 'next': 'wallet_password', 'last': self.last_if_single_password }, + 'imported': { + 'next': 'wallet_password', + 'last': self.last_if_single_password + }, 'wallet_password': { 'last': True } @@ -180,10 +185,12 @@ class NewWalletWizard(AbstractWizard): return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_type'] == 'bip39' def on_wallet_type(self, wizard_data): - if wizard_data['wallet_type'] == '2fa': - return 'trustedcoin_start' - - return 'keystore_type' + t = wizard_data['wallet_type'] + return { + 'standard': 'keystore_type', + '2fa': 'trustedcoin_start', + 'imported': 'imported' + }.get(t) def on_keystore_type(self, wizard_data): t = wizard_data['keystore_type'] @@ -205,13 +212,28 @@ class NewWalletWizard(AbstractWizard): def create_storage(self, path, data): # only standard and 2fa wallets for now - assert data['wallet_type'] in ['standard', '2fa'] + assert data['wallet_type'] in ['standard', '2fa', 'imported'] if os.path.exists(path): raise Exception('file already exists at path') storage = WalletStorage(path) - if data['keystore_type'] in ['createseed', 'haveseed']: + k = None + if not 'keystore_type' in data: + assert data['wallet_type'] == 'imported' + addresses = {} + if 'private_key_list' in data: + k = keystore.Imported_KeyStore({}) + keys = keystore.get_private_keys(data['private_key_list']) + for pk in keys: + assert bitcoin.is_private_key(pk) + txin_type, pubkey = k.import_privkey(pk, None) + addr = bitcoin.pubkey_to_address(txin_type, pubkey) + addresses[addr] = {'type': txin_type, 'pubkey': pubkey} + elif 'address_list' in data: + for addr in data['address_list'].split(): + addresses[addr] = {} + elif data['keystore_type'] in ['createseed', 'haveseed']: if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit self._logger.debug('creating keystore from electrum seed') k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') @@ -237,7 +259,7 @@ class NewWalletWizard(AbstractWizard): raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) if data['encrypt']: - if k.may_have_password(): + if k and k.may_have_password(): k.update_password(None, data['password']) storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) @@ -255,8 +277,12 @@ class NewWalletWizard(AbstractWizard): db.put('x2/', data['x2/']) db.put('x3/', data['x3/']) db.put('use_trustedcoin', True) + elif data['wallet_type'] == 'imported': + if k: + db.put('keystore', k.dump()) + db.put('addresses', addresses) - if k.can_have_deterministic_lightning_xprv(): + if k and k.can_have_deterministic_lightning_xprv(): db.put('lightning_xprv', k.get_lightning_xprv(data['password'] if data['encrypt'] else None)) db.load_plugins() From 15e2ed4f5823d80ddd584568e8ebcbb39e6b3abe Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 4 Oct 2022 19:47:29 +0200 Subject: [PATCH 09/15] rewrite server connect wizard to new wizard style 'last' property on WizardComponents is now queried from UI, not from the wizard. This allows the content of the WizardComponent itself to be taken into account. --- .../gui/qml/components/NewWalletWizard.qml | 4 +-- .../qml/components/ServerConnectWizard.qml | 28 +++---------------- .../qml/components/wizard/WCAutoConnect.qml | 7 ++--- electrum/gui/qml/components/wizard/Wizard.qml | 5 ++-- .../qml/components/wizard/WizardComponent.qml | 15 ++++++++++ electrum/gui/qml/qeapp.py | 3 +- electrum/gui/qml/qedaemon.py | 11 +++++++- electrum/gui/qml/qewizard.py | 16 ++++++++++- electrum/gui/wizard.py | 24 ++++++++++++++++ 9 files changed, 76 insertions(+), 37 deletions(-) diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml index 1f47eb7af..a391a2525 100644 --- a/electrum/gui/qml/components/NewWalletWizard.qml +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -15,9 +15,7 @@ Wizard { property string path - enter: null // disable transition - - property QtObject wiz: Daemon.newWalletWizard + wiz: Daemon.newWalletWizard Component.onCompleted: { var view = wiz.start_wizard() diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml index 0f80d47c7..f126a831f 100644 --- a/electrum/gui/qml/components/ServerConnectWizard.qml +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -11,6 +11,8 @@ Wizard { enter: null // disable transition + wiz: Daemon.serverConnectWizard + onAccepted: { var proxy = wizard_data['proxy'] if (proxy && proxy['enabled'] == true) { @@ -25,29 +27,7 @@ Wizard { } Component.onCompleted: { - var start = _loadNextComponent(autoconnect) - start.next.connect(function() {autoconnectDone()}) - } - - function autoconnectDone() { - var page = _loadNextComponent(proxyconfig, wizard_data) - page.next.connect(function() {proxyconfigDone()}) - } - - function proxyconfigDone() { - var page = _loadNextComponent(serverconfig, wizard_data) - } - - property Component autoconnect: Component { - WCAutoConnect {} - } - - property Component proxyconfig: Component { - WCProxyConfig {} + var view = wiz.start_wizard() + _loadNextComponent(view) } - - property Component serverconfig: Component { - WCServerConfig {} - } - } diff --git a/electrum/gui/qml/components/wizard/WCAutoConnect.qml b/electrum/gui/qml/components/wizard/WCAutoConnect.qml index 9b15d533c..51c4625a7 100644 --- a/electrum/gui/qml/components/wizard/WCAutoConnect.qml +++ b/electrum/gui/qml/components/wizard/WCAutoConnect.qml @@ -1,14 +1,12 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 -import ".." import "../controls" WizardComponent { valid: true - last: serverconnectgroup.checkedButton.connecttype === 'auto' - onAccept: { + function apply() { wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' } @@ -22,17 +20,18 @@ WizardComponent { ButtonGroup { id: serverconnectgroup + onCheckedButtonChanged: checkIsLast() } RadioButton { ButtonGroup.group: serverconnectgroup property string connecttype: 'auto' text: qsTr('Auto connect') + checked: true } RadioButton { ButtonGroup.group: serverconnectgroup property string connecttype: 'manual' - checked: true text: qsTr('Select servers manually') } diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml index 73874590a..6a80d1fb4 100644 --- a/electrum/gui/qml/components/wizard/Wizard.qml +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -11,7 +11,8 @@ Dialog { height: parent.height property var wizard_data - property alias pages : pages + property alias pages: pages + property QtObject wiz function _setWizardData(wdata) { wizard_data = {} @@ -49,7 +50,6 @@ Dialog { 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') } @@ -57,7 +57,6 @@ Dialog { 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 diff --git a/electrum/gui/qml/components/wizard/WizardComponent.qml b/electrum/gui/qml/components/wizard/WizardComponent.qml index 3ee0fbc22..25a9b7072 100644 --- a/electrum/gui/qml/components/wizard/WizardComponent.qml +++ b/electrum/gui/qml/components/wizard/WizardComponent.qml @@ -8,4 +8,19 @@ Item { property bool valid property bool last: false property bool ready: false + + onAccept: { + apply() + } + + function apply() { } + function checkIsLast() { + apply() + last = wizard.wiz.isLast(wizard_data) + } + + Component.onCompleted: { + checkIsLast() + } + } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 9745bcd7c..c21aefee3 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -29,7 +29,7 @@ from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails from .qechanneldetails import QEChannelDetails from .qeswaphelper import QESwapHelper -from .qewizard import QENewWalletWizard +from .qewizard import QENewWalletWizard, QEServerConnectWizard notification = None @@ -219,6 +219,7 @@ class ElectrumQmlApplication(QGuiApplication): 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') + qmlRegisterUncreatableType(QEServerConnectWizard, 'org.electrum', 1, 0, 'ServerConnectWizard', 'ServerConnectWizard can only be used as property') self.engine = QQmlApplicationEngine(parent=self) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index dfd21e32f..5ae0e8232 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -14,7 +14,7 @@ from .auth import AuthMixin, auth_protect from .qefx import QEFX from .qewallet import QEWallet from .qewalletdb import QEWalletDB -from .qewizard import QENewWalletWizard +from .qewizard import QENewWalletWizard, QEServerConnectWizard # wallet list model. supports both wallet basenames (wallet file basenames) # and whole Wallet instances (loaded wallets) @@ -123,6 +123,7 @@ class QEDaemon(AuthMixin, QObject): _available_wallets = None _current_wallet = None _new_wallet_wizard = None + _server_connect_wizard = None _path = None _use_single_password = False _password = None @@ -131,6 +132,7 @@ class QEDaemon(AuthMixin, QObject): availableWalletsChanged = pyqtSignal() fxChanged = pyqtSignal() newWalletWizardChanged = pyqtSignal() + serverConnectWizardChanged = pyqtSignal() walletLoaded = pyqtSignal() walletRequiresPassword = pyqtSignal() @@ -296,3 +298,10 @@ class QEDaemon(AuthMixin, QObject): self._new_wallet_wizard = QENewWalletWizard(self) return self._new_wallet_wizard + + @pyqtProperty(QEServerConnectWizard, notify=serverConnectWizardChanged) + def serverConnectWizard(self): + if not self._server_connect_wizard: + self._server_connect_wizard = QEServerConnectWizard(self) + + return self._server_connect_wizard diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index 0cb5b8a43..34cdbf079 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -4,7 +4,7 @@ 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.gui.wizard import NewWalletWizard, ServerConnectWizard class QEAbstractWizard(QObject): _logger = get_logger(__name__) @@ -99,3 +99,17 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): except Exception as e: self._logger.error(repr(e)) self.createError.emit(str(e)) + +class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard): + + def __init__(self, daemon, parent = None): + ServerConnectWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, parent) + self._daemon = daemon + + # attach view names + self.navmap_merge({ + 'autoconnect': { 'gui': 'WCAutoConnect' }, + 'proxy_config': { 'gui': 'WCProxyConfig' }, + 'server_config': { 'gui': 'WCServerConfig' }, + }) diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py index 869a94ef3..8a128b997 100644 --- a/electrum/gui/wizard.py +++ b/electrum/gui/wizard.py @@ -287,3 +287,27 @@ class NewWalletWizard(AbstractWizard): db.load_plugins() db.write(storage) + +class ServerConnectWizard(AbstractWizard): + + _logger = get_logger(__name__) + + def __init__(self, daemon): + self.navmap = { + 'autoconnect': { + 'next': 'proxy_config', + 'last': lambda v,d: d['autoconnect'] + }, + 'proxy_config': { + 'next': 'server_config' + }, + 'server_config': { + 'last': True + } + } + self._daemon = daemon + + def start(self, initial_data = {}): + self.reset() + self._current = WizardViewState('autoconnect', initial_data, {}) + return self._current From c6496d02efd6c4cf003b51dd17b0c50ce649efd8 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 4 Oct 2022 21:10:17 +0200 Subject: [PATCH 10/15] add recovery paths (disable and confirm/reset OTP) for 2FA --- electrum/gui/qml/qewizard.py | 2 +- electrum/gui/wizard.py | 12 +- electrum/plugins/trustedcoin/qml.py | 150 +++++++++++++----- .../plugins/trustedcoin/qml/KeepDisable.qml | 35 ++++ .../trustedcoin/qml/ShowConfirmOTP.qml | 54 +++++-- 5 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 electrum/plugins/trustedcoin/qml/KeepDisable.qml diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index 34cdbf079..1251117b9 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -73,7 +73,7 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): self._path = path self.pathChanged.emit() - def last_if_single_password(self, view, wizard_data): + def last_if_single_password(self, *args): return self._daemon.singlePasswordEnabled @pyqtSlot('QJSValue', bool, str) diff --git a/electrum/gui/wizard.py b/electrum/gui/wizard.py index 8a128b997..3f9fdb5d4 100644 --- a/electrum/gui/wizard.py +++ b/electrum/gui/wizard.py @@ -234,7 +234,7 @@ class NewWalletWizard(AbstractWizard): for addr in data['address_list'].split(): addresses[addr] = {} elif data['keystore_type'] in ['createseed', 'haveseed']: - if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit + if data['seed_type'] in ['old', 'standard', 'segwit']: self._logger.debug('creating keystore from electrum seed') k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') elif data['seed_type'] == 'bip39': @@ -243,7 +243,7 @@ class NewWalletWizard(AbstractWizard): derivation = normalize_bip32_derivation(data['derivation_path']) script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) - elif data['seed_type'] == '2fa_segwit': # TODO: legacy 2fa + elif data['seed_type'] == '2fa_segwit': # TODO: legacy 2fa '2fa' self._logger.debug('creating keystore from 2fa seed') k = keystore.from_xprv(data['x1/']['xprv']) else: @@ -274,7 +274,13 @@ class NewWalletWizard(AbstractWizard): db.put('keystore', k.dump()) elif data['wallet_type'] == '2fa': db.put('x1/', k.dump()) - db.put('x2/', data['x2/']) + if data['trustedcoin_keepordisable'] == 'disable': + k2 = keystore.from_xprv(data['x2/']['xprv']) + if data['encrypt'] and k2.may_have_password(): + k2.update_password(None, data['password']) + db.put('x2/', k2.dump()) + else: + db.put('x2/', data['x2/']) db.put('x3/', data['x3/']) db.put('use_trustedcoin', True) elif data['wallet_type'] == 'imported': diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 364178248..dde9fb929 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,12 +1,13 @@ import threading import socket +import base64 from typing import TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot from electrum.i18n import _ from electrum.plugin import hook -from electrum.bip32 import xpub_type +from electrum.bip32 import xpub_type, BIP32Node from electrum.util import UserFacingException from electrum import keystore @@ -30,9 +31,7 @@ class Plugin(TrustedCoinPlugin): _termsAndConditions = '' termsAndConditionsErrorChanged = pyqtSignal() _termsAndConditionsError = '' - createRemoteKeyErrorChanged = pyqtSignal() - _createRemoteKeyError = '' - otpError = pyqtSignal() + otpError = pyqtSignal([str], arguments=['message']) otpSuccess = pyqtSignal() disclaimerChanged = pyqtSignal() keystoreChanged = pyqtSignal() @@ -41,6 +40,10 @@ class Plugin(TrustedCoinPlugin): shortIdChanged = pyqtSignal() _shortId = '' + _remoteKeyState = '' + remoteKeyStateChanged = pyqtSignal() + remoteKeyError = pyqtSignal([str], arguments=['message']) + requestOtp = pyqtSignal() def __init__(self, plugin, parent): @@ -81,9 +84,15 @@ class Plugin(TrustedCoinPlugin): def termsAndConditionsError(self): return self._termsAndConditionsError - @pyqtProperty(str, notify=createRemoteKeyErrorChanged) - def createRemoteKeyError(self): - return self._createRemoteKeyError + @pyqtProperty(str, notify=remoteKeyStateChanged) + def remoteKeyState(self): + return self._remoteKeyState + + @remoteKeyState.setter + def remoteKeyState(self, new_state): + if self._remoteKeyState != new_state: + self._remoteKeyState = new_state + self.remoteKeyStateChanged.emit() @pyqtSlot() def fetchTermsAndConditions(self): @@ -112,7 +121,8 @@ class Plugin(TrustedCoinPlugin): @pyqtSlot(str) def createKeystore(self, email): - xprv1, xpub1, xpub2, xpub3, short_id = self.plugin.create_keys() + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() def create_remote_key_task(): try: self.plugin.logger.debug('create remote key') @@ -121,25 +131,32 @@ class Plugin(TrustedCoinPlugin): otp_secret = r['otp_secret'] _xpub3 = r['xpubkey_cosigner'] _id = r['id'] - except (socket.error, ErrorConnectingServer): - self._createRemoteKeyError = _('Error creating key') - self.createRemoteKeyErrorChanged.emit() + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') except TrustedCoinException as e: - # if e.status_code == 409: TODO ? - # r = None - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + if e.status_code == 409: + self.remoteKeyState = 'wallet_known' + self._shortId = short_id + self.shortIdChanged.emit() + else: + self.remoteKeyState = 'error' + self.logger.warning(str(e)) + self.remoteKeyError.emit(f'Service error: {str(e)}') except (KeyError,TypeError) as e: # catch any assumptions - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + self.logger.error(str(e)) else: if short_id != _id: - self._createRemoteKeyError = "unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) + self.remoteKeyError.emit('Unexpected short_id') return if xpub3 != _xpub3: - self._createRemoteKeyError = "unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3) - self.createRemoteKeyErrorChanged.emit() + self.remoteKeyState = 'error' + self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) + self.remoteKeyError.emit('Unexpected trustedcoin xpub3') return self._otpSecret = otp_secret self.otpSecretChanged.emit() @@ -151,10 +168,49 @@ class Plugin(TrustedCoinPlugin): self._busy = True self.busyChanged.emit() + t = threading.Thread(target=create_remote_key_task) t.daemon = True t.start() + @pyqtSlot() + def resetOtpSecret(self): + self.remoteKeyState = '' + xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys() + def reset_otp_task(): + try: + self.plugin.logger.debug('reset_otp') + r = server.get_challenge(short_id) + challenge = r.get('challenge') + message = 'TRUSTEDCOIN CHALLENGE: ' + challenge + def f(xprv): + rootnode = BIP32Node.from_xkey(xprv) + key = rootnode.subkey_at_private_derivation((0, 0)).eckey + sig = key.sign_message(message, True) + return base64.b64encode(sig).decode() + + signatures = [f(x) for x in [xprv1, xprv2]] + r = server.reset_auth(short_id, challenge, signatures) + otp_secret = r.get('otp_secret') + except (socket.error, ErrorConnectingServer) as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Network error: {str(e)}') + except Exception as e: + self.remoteKeyState = 'error' + self.remoteKeyError.emit(f'Error: {str(e)}') + else: + self._otpSecret = otp_secret + self.otpSecretChanged.emit() + finally: + self._busy = False + self.busyChanged.emit() + + self._busy = True + self.busyChanged.emit() + + t = threading.Thread(target=reset_otp_task, daemon=True) + t.start() + @pyqtSlot(str, int) def checkOtp(self, short_id, otp): def check_otp_task(): @@ -164,15 +220,13 @@ class Plugin(TrustedCoinPlugin): except TrustedCoinException as e: if e.status_code == 400: # invalid OTP self.plugin.logger.debug('Invalid one-time password.') - self.otpError.emit() + self.otpError.emit(_('Invalid one-time password.')) else: self.plugin.logger.error(str(e)) - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.otpError.emit(f'Service error: {str(e)}') except Exception as e: self.plugin.logger.error(str(e)) - self._createRemoteKeyError = str(e) - self.createRemoteKeyErrorChanged.emit() + self.otpError.emit(f'Error: {str(e)}') else: self.plugin.logger.debug('OTP verify success') self.otpSuccess.emit() @@ -182,8 +236,7 @@ class Plugin(TrustedCoinPlugin): self._busy = True self.busyChanged.emit() - t = threading.Thread(target=check_otp_task) - t.daemon = True + t = threading.Thread(target=check_otp_task, daemon=True) t.start() @@ -204,6 +257,7 @@ class Plugin(TrustedCoinPlugin): _('This wallet was restored from seed, and it contains two master private keys.'), _('Therefore, two-factor authentication is disabled.') ]) + self.logger.info(msg) #action = lambda: window.show_message(msg) #else: #action = partial(self.settings_dialog, window) @@ -233,7 +287,8 @@ class Plugin(TrustedCoinPlugin): }, 'trustedcoin_choose_seed': { 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', - 'next': self.on_choose_seed + 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' + else 'trustedcoin_have_seed' }, 'trustedcoin_create_seed': { 'gui': 'WCCreateSeed', @@ -245,7 +300,14 @@ class Plugin(TrustedCoinPlugin): }, 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', - 'next': 'trustedcoin_tos_email' + 'next': 'trustedcoin_keep_disable' + }, + 'trustedcoin_keep_disable': { + 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable', + 'next': lambda d: 'trustedcoin_tos_email' if d['trustedcoin_keepordisable'] != 'disable' + else 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda v,d: wizard.last_if_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, 'trustedcoin_tos_email': { 'gui': '../../../../plugins/trustedcoin/qml/Terms', @@ -260,12 +322,6 @@ class Plugin(TrustedCoinPlugin): } wizard.navmap_merge(views) - def on_choose_seed(self, wizard_data): - self.logger.debug('on_choose_seed') - if wizard_data['keystore_type'] == 'createseed': - return 'trustedcoin_create_seed' - else: - return 'trustedcoin_have_seed' # combined create_keystore and create_remote_key pre def create_keys(self): @@ -286,21 +342,33 @@ class Plugin(TrustedCoinPlugin): xtype = xpub_type(xpub1) xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) - return (xprv1,xpub1,xpub2,xpub3,short_id) + return (xprv1,xpub1,xprv2,xpub2,xpub3,short_id) def on_accept_otp_secret(self, wizard_data): - self.logger.debug('on accept otp: ' + repr(wizard_data)) + self.logger.debug('OTP secret accepted, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() + k1 = keystore.from_xprv(xprv1) + k2 = keystore.from_xpub(xpub2) + k3 = keystore.from_xpub(xpub3) + + wizard_data['x1/'] = k1.dump() + wizard_data['x2/'] = k2.dump() + wizard_data['x3/'] = k3.dump() - xprv1,xpub1,xpub2,xpub3,short_id = self.create_keys() + def recovery_disable(self, wizard_data): + if wizard_data['trustedcoin_keepordisable'] != 'disable': + return + self.logger.debug('2fa disabled, creating keystores') + xprv1,xpub1,xprv2,xpub2,xpub3,short_id = self.create_keys() k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) + k2 = keystore.from_xprv(xprv2) k3 = keystore.from_xpub(xpub3) wizard_data['x1/'] = k1.dump() wizard_data['x2/'] = k2.dump() wizard_data['x3/'] = k3.dump() - # wizard_data['use_trustedcoin'] = True + # regular wallet prompt functions diff --git a/electrum/plugins/trustedcoin/qml/KeepDisable.qml b/electrum/plugins/trustedcoin/qml/KeepDisable.qml new file mode 100644 index 000000000..3e10d5175 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/KeepDisable.qml @@ -0,0 +1,35 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import "../../../gui/qml/components/wizard" + +WizardComponent { + valid: keepordisablegroup.checkedButton + + function apply() { + wizard_data['trustedcoin_keepordisable'] = keepordisablegroup.checkedButton.keepordisable + } + + ButtonGroup { + id: keepordisablegroup + onCheckedButtonChanged: checkIsLast() + } + + ColumnLayout { + Label { + text: qsTr('Restore 2FA wallet') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'keep' + checked: true + text: qsTr('Keep') + } + RadioButton { + ButtonGroup.group: keepordisablegroup + property string keepordisable: 'disable' + text: qsTr('Disable') + } + } +} diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml index da2e2eb0b..849111a12 100644 --- a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml +++ b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml @@ -12,6 +12,10 @@ WizardComponent { property bool otpVerified: false + function apply() { + wizard_data['trustedcoin_new_otp_secret'] = requestNewSecret.checked + } + ColumnLayout { width: parent.width @@ -20,16 +24,24 @@ WizardComponent { } InfoTextArea { + id: errorBox iconStyle: InfoTextArea.IconStyle.Error - visible: plugin ? plugin.createRemoteKeyError : false - text: plugin ? plugin.createRemoteKeyError : '' + visible: !otpVerified && plugin.remoteKeyState == 'error' + } + + InfoTextArea { + iconStyle: InfoTextArea.IconStyle.Warn + visible: plugin.remoteKeyState == 'wallet_known' + text: qsTr('This wallet is already registered with TrustedCoin. ') + + qsTr('To finalize wallet creation, please enter your Google Authenticator Code. ') } QRImage { Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == '' qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name'] + '?secret=' + plugin.otpSecret + '&digits=6') - render: plugin ? plugin.otpSecret : false + render: plugin.otpSecret } TextHighlightPane { @@ -43,17 +55,24 @@ WizardComponent { } Label { + visible: !otpVerified && plugin.otpSecret Layout.preferredWidth: parent.width wrapMode: Text.Wrap text: qsTr('Enter or scan into authenticator app. Then authenticate below') - visible: plugin.otpSecret && !otpVerified + } + + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('If you still have your OTP secret, then authenticate below') } TextField { id: otp_auth + visible: !otpVerified && (plugin.otpSecret || plugin.remoteKeyState == 'wallet_known') Layout.alignment: Qt.AlignHCenter focus: true - visible: plugin.otpSecret && !otpVerified inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly font.family: FixedFont font.pixelSize: constants.fontSizeLarge @@ -65,12 +84,26 @@ WizardComponent { } } + Label { + visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' + Layout.preferredWidth: parent.width + wrapMode: Text.Wrap + text: qsTr('Otherwise, you can request your OTP secret from the server, by pressing the button below') + } + + Button { + Layout.alignment: Qt.AlignHCenter + visible: plugin.remoteKeyState == 'wallet_known' && !otpVerified + text: qsTr('Request OTP secret') + onClicked: plugin.resetOtpSecret() + } + Image { Layout.alignment: Qt.AlignHCenter source: '../../../gui/icons/confirmed.png' visible: otpVerified - Layout.preferredWidth: constants.iconSizeLarge - Layout.preferredHeight: constants.iconSizeLarge + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge } } @@ -88,14 +121,17 @@ WizardComponent { Connections { target: plugin - function onOtpError() { + function onOtpError(message) { console.log('OTP verify error') - // TODO: show error in UI + errorBox.text = message } function onOtpSuccess() { console.log('OTP verify success') otpVerified = true } + function onRemoteKeyError(message) { + errorBox.text = message + } } } From 28b5103d93f417c9de7e4691427375efeccc4404 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 6 Oct 2022 09:20:02 +0200 Subject: [PATCH 11/15] remove qml_test plugin, remove plugin settings test --- electrum/plugins/qml_test/__init__.py | 5 -- electrum/plugins/qml_test/qml.py | 15 ------ electrum/plugins/trustedcoin/qml.py | 3 -- electrum/plugins/trustedcoin/qml/Settings.qml | 46 ------------------- 4 files changed, 69 deletions(-) delete mode 100644 electrum/plugins/qml_test/__init__.py delete mode 100644 electrum/plugins/qml_test/qml.py delete mode 100644 electrum/plugins/trustedcoin/qml/Settings.qml diff --git a/electrum/plugins/qml_test/__init__.py b/electrum/plugins/qml_test/__init__.py deleted file mode 100644 index 62e390176..000000000 --- a/electrum/plugins/qml_test/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from electrum.i18n import _ - -fullname = 'QML Plugin Test' -description = '%s\n%s' % (_("Plugin to test QML integration from plugins."), _("Note: Used for development")) -available_for = ['qml'] diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py deleted file mode 100644 index 0d5233812..000000000 --- a/electrum/plugins/qml_test/qml.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import TYPE_CHECKING -from PyQt5.QtQml import QQmlApplicationEngine -from electrum.plugin import hook, BasePlugin -from electrum.logging import get_logger - -if TYPE_CHECKING: - from electrum.gui.qml import ElectrumGui - -class Plugin(BasePlugin): - def __init__(self, parent, config, name): - BasePlugin.__init__(self, parent, config, name) - - @hook - def init_qml(self, gui: 'ElectrumGui'): - self.logger.debug('init_qml hook called') diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index dde9fb929..85b7e11b0 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -49,9 +49,6 @@ class Plugin(TrustedCoinPlugin): 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) diff --git a/electrum/plugins/trustedcoin/qml/Settings.qml b/electrum/plugins/trustedcoin/qml/Settings.qml deleted file mode 100644 index c8b7560f4..000000000 --- a/electrum/plugins/trustedcoin/qml/Settings.qml +++ /dev/null @@ -1,46 +0,0 @@ -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') - } - } - -} From 6ca733a12f621904816488aa10a1b85d711292c1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Oct 2022 11:59:31 +0200 Subject: [PATCH 12/15] move /gui/wizard.py to /wizard.py --- electrum/gui/qml/qewizard.py | 2 +- electrum/{gui => }/wizard.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename electrum/{gui => }/wizard.py (100%) diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py index 1251117b9..1f5daa8f8 100644 --- a/electrum/gui/qml/qewizard.py +++ b/electrum/gui/qml/qewizard.py @@ -4,7 +4,7 @@ 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, ServerConnectWizard +from electrum.wizard import NewWalletWizard, ServerConnectWizard class QEAbstractWizard(QObject): _logger = get_logger(__name__) diff --git a/electrum/gui/wizard.py b/electrum/wizard.py similarity index 100% rename from electrum/gui/wizard.py rename to electrum/wizard.py From 0a9c100382e9eb5801a313660199777730b79a27 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 24 Oct 2022 10:38:43 +0200 Subject: [PATCH 13/15] force subclassing of self.last_if_single_password in NewWalletWizard --- electrum/wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electrum/wizard.py b/electrum/wizard.py index 3f9fdb5d4..c8bd7b2f3 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -179,7 +179,7 @@ class NewWalletWizard(AbstractWizard): return self._current def last_if_single_password(self, view, wizard_data): - return False # TODO: self._daemon.config.get('single_password') + raise NotImplementedError() def last_if_single_password_and_not_bip39(self, view, wizard_data): return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_type'] == 'bip39' From fac4003354bcbbfdeba027a50279d03177d1738a Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 24 Oct 2022 12:42:45 +0200 Subject: [PATCH 14/15] qml: fix wizard_data not available to 'last' check on have seed wizard page also refactor seed verification, split off seed_variant from seed_type (partial disambiguation), fix bip39 wallet creation --- .../qml/components/wizard/WCBIP39Refine.qml | 4 +-- .../gui/qml/components/wizard/WCHaveSeed.qml | 29 ++++++++++--------- electrum/gui/qml/qebitcoin.py | 22 ++++++-------- electrum/wizard.py | 2 +- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index 27cb36dd4..cef0d198d 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -51,10 +51,9 @@ WizardComponent { clip:true interactive: height < contentHeight - GridLayout { + ColumnLayout { id: mainLayout width: parent.width - columns: 1 Label { text: qsTr('Script type and Derivation path') } Button { @@ -79,6 +78,7 @@ WizardComponent { text: qsTr('native segwit (p2wpkh)') } InfoTextArea { + Layout.preferredWidth: parent.width text: qsTr('You can override the suggested derivation path.') + ' ' + qsTr('If you are not sure what this is, leave this field unchanged.') } diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml index 527de82a1..2d140f98d 100644 --- a/electrum/gui/qml/components/wizard/WCHaveSeed.qml +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -14,38 +14,37 @@ WizardComponent { property bool is2fa: false - onAccept: { + function apply() { wizard_data['seed'] = seedtext.text + wizard_data['seed_variant'] = seed_variant.currentValue wizard_data['seed_type'] = bitcoin.seed_type wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' - wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' - wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' } function setSeedTypeHelpText() { var t = { - 'Electrum': [ + 'electrum': [ qsTr('Electrum seeds are the default seed type.'), qsTr('If you are restoring from a seed previously created by Electrum, choose this option') ].join(' '), - 'BIP39': [ + 'bip39': [ qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), '

', qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.') ].join(' '), - 'SLIP39': [ + 'slip39': [ qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), '

', qsTr('However, we do not generate SLIP39 seeds.') ].join(' ') } - infotext.text = t[seed_type.currentText] + infotext.text = t[seed_variant.currentValue] } function checkValid() { - bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39', wizard_data['wallet_type']) + bitcoin.verify_seed(seedtext.text, seed_variant.currentValue, wizard_data['wallet_type']) } Flickable { @@ -65,16 +64,20 @@ WizardComponent { Layout.fillWidth: true } ComboBox { - id: seed_type + id: seed_variant visible: !is2fa - model: ['Electrum', 'BIP39'/*, 'SLIP39'*/] + + textRole: 'text' + valueRole: 'value' + model: [ + { text: qsTr('Electrum'), value: 'electrum' }, + { text: qsTr('BIP39'), value: 'bip39' } + ] onActivated: { setSeedTypeHelpText() + checkIsLast() checkValid() } - function getTypeCode() { - return currentText - } } InfoTextArea { id: infotext diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 4d37d1206..70dd6437f 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -68,23 +68,18 @@ class QEBitcoin(QObject): asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop()) - @pyqtSlot(str) - @pyqtSlot(str,bool,bool) - @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)) - + @pyqtSlot(str,str) + @pyqtSlot(str,str,str) + def verify_seed(self, seed, seed_variant, wallet_type='standard'): seed_type = '' seed_valid = False self.validationMessage = '' - if not (bip39 or slip39): + if seed_variant == 'electrum': seed_type = mnemonic.seed_type(seed) if seed_type != '': seed_valid = True - elif bip39: + elif seed_variant == 'bip39': is_checksum, is_wordlist = keystore.bip39_is_checksum_valid(seed) status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' self.validationMessage = 'BIP39 (%s)' % status @@ -92,8 +87,7 @@ class QEBitcoin(QObject): if is_checksum: seed_type = 'bip39' seed_valid = True - - elif slip39: # TODO: incomplete impl, this code only validates a single share. + elif seed_variant == 'slip39': # TODO: incomplete impl, this code only validates a single share. try: share = decode_mnemonic(seed) seed_type = 'slip39' @@ -101,11 +95,13 @@ class QEBitcoin(QObject): except Slip39Error as e: self.validationMessage = 'SLIP39: %s' % str(e) seed_valid = False # for now + else: + raise Exception(f'unknown seed variant {seed_variant}') # 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']: + elif wallet_type == 'standard' and seed_type not in ['old', 'standard', 'segwit', 'bip39']: seed_valid = False self.seedType = seed_type diff --git a/electrum/wizard.py b/electrum/wizard.py index c8bd7b2f3..8f8d6378c 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -182,7 +182,7 @@ class NewWalletWizard(AbstractWizard): raise NotImplementedError() def last_if_single_password_and_not_bip39(self, view, wizard_data): - return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_type'] == 'bip39' + return self.last_if_single_password(view, wizard_data) and not wizard_data['seed_variant'] == 'bip39' def on_wallet_type(self, wizard_data): t = wizard_data['wallet_type'] From 2adf03477109f774b92e87529b06fd3b71861c8c Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 24 Oct 2022 13:04:46 +0200 Subject: [PATCH 15/15] use apply() function on every wizard page, making sure wizard_data is always available to wizard navigation evaluation --- electrum/gui/qml/components/wizard/WCBIP39Refine.qml | 3 ++- electrum/gui/qml/components/wizard/WCCreateSeed.qml | 2 +- electrum/gui/qml/components/wizard/WCHaveMasterKey.qml | 2 +- electrum/gui/qml/components/wizard/WCImport.qml | 2 +- electrum/gui/qml/components/wizard/WCKeystoreType.qml | 2 +- electrum/gui/qml/components/wizard/WCProxyConfig.qml | 2 +- electrum/gui/qml/components/wizard/WCServerConfig.qml | 2 +- electrum/gui/qml/components/wizard/WCWalletName.qml | 2 +- electrum/gui/qml/components/wizard/WCWalletPassword.qml | 2 +- electrum/gui/qml/components/wizard/WCWalletType.qml | 2 +- 10 files changed, 11 insertions(+), 10 deletions(-) diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml index cef0d198d..2c4dc8ef2 100644 --- a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -10,10 +10,11 @@ import "../controls" WizardComponent { valid: false - onAccept: { + function apply() { wizard_data['script_type'] = scripttypegroup.checkedButton.scripttype wizard_data['derivation_path'] = derivationpathtext.text } + function getScriptTypePurposeDict() { return { 'p2pkh': 44, diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml index b7ac856b5..ee8c3ba1e 100644 --- a/electrum/gui/qml/components/wizard/WCCreateSeed.qml +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -10,7 +10,7 @@ import "../controls" WizardComponent { valid: seedtext.text != '' - onAccept: { + function apply() { wizard_data['seed'] = seedtext.text wizard_data['seed_extend'] = extendcb.checked wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index 904432fde..f97abd7f1 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -11,7 +11,7 @@ WizardComponent { valid: false - onAccept: { + function apply() { wizard_data['master_key'] = masterkey_ta.text } diff --git a/electrum/gui/qml/components/wizard/WCImport.qml b/electrum/gui/qml/components/wizard/WCImport.qml index 7f479f9ee..5158d3fd5 100644 --- a/electrum/gui/qml/components/wizard/WCImport.qml +++ b/electrum/gui/qml/components/wizard/WCImport.qml @@ -11,7 +11,7 @@ WizardComponent { valid: false - onAccept: { + function apply() { if (bitcoin.isAddressList(import_ta.text)) { wizard_data['address_list'] = import_ta.text } else if (bitcoin.isPrivateKeyList(import_ta.text)) { diff --git a/electrum/gui/qml/components/wizard/WCKeystoreType.qml b/electrum/gui/qml/components/wizard/WCKeystoreType.qml index e8837fe9d..30cb41a9d 100644 --- a/electrum/gui/qml/components/wizard/WCKeystoreType.qml +++ b/electrum/gui/qml/components/wizard/WCKeystoreType.qml @@ -4,7 +4,7 @@ import QtQuick.Controls 2.1 WizardComponent { valid: keystoregroup.checkedButton !== null - onAccept: { + function apply() { wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype } diff --git a/electrum/gui/qml/components/wizard/WCProxyConfig.qml b/electrum/gui/qml/components/wizard/WCProxyConfig.qml index ddc0fc60d..2943e1b3d 100644 --- a/electrum/gui/qml/components/wizard/WCProxyConfig.qml +++ b/electrum/gui/qml/components/wizard/WCProxyConfig.qml @@ -3,7 +3,7 @@ import "../controls" WizardComponent { valid: true - onAccept: { + function apply() { wizard_data['proxy'] = pc.toProxyDict() } diff --git a/electrum/gui/qml/components/wizard/WCServerConfig.qml b/electrum/gui/qml/components/wizard/WCServerConfig.qml index b1a3afea5..939eb1f06 100644 --- a/electrum/gui/qml/components/wizard/WCServerConfig.qml +++ b/electrum/gui/qml/components/wizard/WCServerConfig.qml @@ -4,7 +4,7 @@ WizardComponent { valid: true last: true - onAccept: { + function apply() { wizard_data['oneserver'] = !sc.auto_server wizard_data['server'] = sc.address } diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml index 6f7cbf784..07d12f8bb 100644 --- a/electrum/gui/qml/components/wizard/WCWalletName.qml +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -7,7 +7,7 @@ import org.electrum 1.0 WizardComponent { valid: wallet_name.text.length > 0 - onAccept: { + function apply() { wizard_data['wallet_name'] = wallet_name.text } diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml index b6c55c69d..5e4921ac5 100644 --- a/electrum/gui/qml/components/wizard/WCWalletPassword.qml +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -7,7 +7,7 @@ import "../controls" WizardComponent { valid: password1.text === password2.text && password1.text.length > 4 - onAccept: { + function apply() { wizard_data['password'] = password1.text wizard_data['encrypt'] = password1.text != '' } diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml index 1c0ee0c7a..a8fc832a4 100644 --- a/electrum/gui/qml/components/wizard/WCWalletType.qml +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -4,7 +4,7 @@ import QtQuick.Controls 2.1 WizardComponent { valid: wallettypegroup.checkedButton !== null - onAccept: { + function apply() { wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype if (wizard_data['wallet_type'] == 'standard') wizard_data['seed_type'] = 'segwit'