You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

651 lines
25 KiB

#!/usr/bin/env python
#
# Electrum - Lightweight Bitcoin Client
# Copyright (C) 2015 Thomas Voegtlin
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from functools import partial
import threading
import os
from typing import TYPE_CHECKING
from PyQt5.QtGui import QPixmap, QMovie, QColor
from PyQt5.QtCore import QObject, pyqtSignal, QSize, Qt
from PyQt5.QtWidgets import (QTextEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout,
QRadioButton, QCheckBox, QLineEdit, QPushButton, QWidget)
from electrum.i18n import _
from electrum.plugin import hook
from electrum.util import is_valid_email
from electrum.logging import Logger, get_logger
from electrum.base_wizard import GoBack, UserCancelled
from electrum.gui.qt.util import (read_QIcon, WindowModalDialog, WaitingDialog, OkButton,
CancelButton, Buttons, icon_path, WWLabel, CloseButton, ChoicesLayout, ColorScheme)
from electrum.gui.qt.qrcodewidget import QRCodeWidget
from electrum.gui.qt.amountedit import AmountEdit
from electrum.gui.qt.main_window import StatusBarButton
from electrum.gui.qt.installwizard import InstallWizard
from electrum.gui.qt.wizard.wallet import WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt
from electrum.gui.qt.wizard.wizard import WizardComponent
from .qt_common import QSignalObject
from .trustedcoin import TrustedCoinPlugin, server, DISCLAIMER
if TYPE_CHECKING:
from electrum.gui.qt.main_window import ElectrumWindow
from electrum.wallet import Abstract_Wallet
from electrum.gui.qt.wizard.wallet import QENewWalletWizard
class TOS(QTextEdit):
tos_signal = pyqtSignal()
error_signal = pyqtSignal(object)
class HandlerTwoFactor(QObject, Logger):
def __init__(self, plugin, window):
QObject.__init__(self)
self.plugin = plugin
self.window = window
Logger.__init__(self)
def prompt_user_for_otp(self, wallet, tx, on_success, on_failure):
if not isinstance(wallet, self.plugin.wallet_class):
return
if wallet.can_sign_without_server():
return
if not wallet.keystores['x3/'].can_sign(tx, ignore_watching_only=True):
self.logger.info("twofactor: xpub3 not needed")
return
window = self.window.top_level_window()
auth_code = self.plugin.auth_dialog(window)
WaitingDialog(parent=window,
message=_('Waiting for TrustedCoin server to sign transaction...'),
task=lambda: wallet.on_otp(tx, auth_code),
on_success=lambda *args: on_success(tx),
on_error=on_failure)
class Plugin(TrustedCoinPlugin):
def __init__(self, parent, config, name):
super().__init__(parent, config, name)
@hook
def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
if not isinstance(wallet, self.wallet_class):
return
wallet.handler_2fa = HandlerTwoFactor(self, window)
if wallet.can_sign_without_server():
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)
icon = read_QIcon("trustedcoin-status-disabled.png")
else:
action = partial(self.settings_dialog, window)
icon = read_QIcon("trustedcoin-status.png")
sb = window.statusBar()
button = StatusBarButton(icon, _("TrustedCoin"), action, sb.height())
sb.addPermanentWidget(button)
self.start_request_thread(window.wallet)
def auth_dialog(self, window):
d = WindowModalDialog(window, _("Authorization"))
vbox = QVBoxLayout(d)
pw = AmountEdit(None, is_int = True)
msg = _('Please enter your Google Authenticator code')
vbox.addWidget(QLabel(msg))
grid = QGridLayout()
grid.setSpacing(8)
grid.addWidget(QLabel(_('Code')), 1, 0)
grid.addWidget(pw, 1, 1)
vbox.addLayout(grid)
msg = _('If you have lost your second factor, you need to restore your wallet from seed in order to request a new code.')
label = QLabel(msg)
label.setWordWrap(1)
vbox.addWidget(label)
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
if not d.exec_():
return
return pw.get_amount()
def prompt_user_for_otp(self, wallet, tx, on_success, on_failure):
wallet.handler_2fa.prompt_user_for_otp(wallet, tx, on_success, on_failure)
def waiting_dialog_for_billing_info(self, window, *, on_finished=None):
def task():
return self.request_billing_info(window.wallet, suppress_connection_error=False)
def on_error(exc_info):
e = exc_info[1]
window.show_error("{header}\n{exc}\n\n{tor}"
.format(header=_('Error getting TrustedCoin account info.'),
exc=repr(e),
tor=_('If you keep experiencing network problems, try using a Tor proxy.')))
return WaitingDialog(parent=window,
message=_('Requesting account info from TrustedCoin server...'),
task=task,
on_success=on_finished,
on_error=on_error)
@hook
def abort_send(self, window):
wallet = window.wallet
if not isinstance(wallet, self.wallet_class):
return
if wallet.can_sign_without_server():
return
if wallet.billing_info is None:
self.waiting_dialog_for_billing_info(window)
return True
return False
def settings_dialog(self, window):
self.waiting_dialog_for_billing_info(window,
on_finished=partial(self.show_settings_dialog, window))
def show_settings_dialog(self, window, success):
if not success:
window.show_message(_('Server not reachable.'))
return
wallet = window.wallet
d = WindowModalDialog(window, _("TrustedCoin Information"))
d.setMinimumSize(500, 200)
vbox = QVBoxLayout(d)
hbox = QHBoxLayout()
logo = QLabel()
logo.setPixmap(QPixmap(icon_path("trustedcoin-status.png")))
msg = _('This wallet is protected by TrustedCoin\'s two-factor authentication.') + '<br/>'\
+ _("For more information, visit") + " <a href=\"https://api.trustedcoin.com/#/electrum-help\">https://api.trustedcoin.com/#/electrum-help</a>"
label = QLabel(msg)
label.setOpenExternalLinks(1)
hbox.addStretch(10)
hbox.addWidget(logo)
hbox.addStretch(10)
hbox.addWidget(label)
hbox.addStretch(10)
vbox.addLayout(hbox)
vbox.addStretch(10)
msg = _('TrustedCoin charges a small fee to co-sign transactions. The fee depends on how many prepaid transactions you buy. An extra output is added to your transaction every time you run out of prepaid transactions.') + '<br/>'
label = QLabel(msg)
label.setWordWrap(1)
vbox.addWidget(label)
vbox.addStretch(10)
grid = QGridLayout()
vbox.addLayout(grid)
price_per_tx = wallet.price_per_tx
n_prepay = wallet.num_prepay()
i = 0
for k, v in sorted(price_per_tx.items()):
if k == 1:
continue
grid.addWidget(QLabel("Pay every %d transactions:"%k), i, 0)
grid.addWidget(QLabel(window.format_amount(v/k) + ' ' + window.base_unit() + "/tx"), i, 1)
b = QRadioButton()
b.setChecked(k == n_prepay)
def on_click(b, k):
self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = k
b.clicked.connect(partial(on_click, k=k))
grid.addWidget(b, i, 2)
i += 1
n = wallet.billing_info.get('tx_remaining', 0)
grid.addWidget(QLabel(_("Your wallet has {} prepaid transactions.").format(n)), i, 0)
vbox.addLayout(Buttons(CloseButton(d)))
d.exec_()
def go_online_dialog(self, wizard: InstallWizard):
msg = [
_("Your wallet file is: {}.").format(os.path.abspath(wizard.path)),
_("You need to be online in order to complete the creation of "
"your wallet. If you generated your seed on an offline "
'computer, click on "{}" to close this window, move your '
"wallet file to an online computer, and reopen it with "
"Electrum.").format(_('Cancel')),
_('If you are online, click on "{}" to continue.').format(_('Next'))
]
msg = '\n\n'.join(msg)
wizard.reset_stack()
try:
wizard.confirm_dialog(title='', message=msg, run_next = lambda x: wizard.run('accept_terms_of_use'))
except (GoBack, UserCancelled):
# user clicked 'Cancel' and decided to move wallet file manually
storage, db = wizard.create_storage(wizard.path)
raise
def accept_terms_of_use(self, window):
vbox = QVBoxLayout()
vbox.addWidget(QLabel(_("Terms of Service")))
tos_e = TOS()
tos_e.setReadOnly(True)
vbox.addWidget(tos_e)
tos_received = False
vbox.addWidget(QLabel(_("Please enter your e-mail address")))
email_e = QLineEdit()
vbox.addWidget(email_e)
next_button = window.next_button
prior_button_text = next_button.text()
next_button.setText(_('Accept'))
def request_TOS():
try:
tos = server.get_terms_of_service()
except Exception as e:
self.logger.exception('Could not retrieve Terms of Service')
tos_e.error_signal.emit(_('Could not retrieve Terms of Service:')
+ '\n' + repr(e))
return
self.TOS = tos
tos_e.tos_signal.emit()
def on_result():
tos_e.setText(self.TOS)
nonlocal tos_received
tos_received = True
set_enabled()
def on_error(msg):
window.show_error(str(msg))
window.terminate()
def set_enabled():
next_button.setEnabled(tos_received and is_valid_email(email_e.text()))
tos_e.tos_signal.connect(on_result)
tos_e.error_signal.connect(on_error)
t = threading.Thread(target=request_TOS)
t.daemon = True
t.start()
email_e.textChanged.connect(set_enabled)
email_e.setFocus(True)
window.exec_layout(vbox, next_enabled=False)
next_button.setText(prior_button_text)
email = str(email_e.text())
self.create_remote_key(email, window)
def request_otp_dialog(self, window, short_id, otp_secret, xpub3):
vbox = QVBoxLayout()
if otp_secret is not None:
uri = "otpauth://totp/%s?secret=%s"%('trustedcoin.com', otp_secret)
l = QLabel("Please scan the following QR code in Google Authenticator. You may as well use the following key: %s"%otp_secret)
l.setWordWrap(True)
vbox.addWidget(l)
qrw = QRCodeWidget(uri)
vbox.addWidget(qrw, 1)
msg = _('Then, enter your Google Authenticator code:')
else:
label = QLabel(
"This wallet is already registered with TrustedCoin. "
"To finalize wallet creation, please enter your Google Authenticator Code. "
)
label.setWordWrap(1)
vbox.addWidget(label)
msg = _('Google Authenticator code:')
hbox = QHBoxLayout()
hbox.addWidget(WWLabel(msg))
pw = AmountEdit(None, is_int = True)
pw.setFocus(True)
pw.setMaximumWidth(50)
hbox.addWidget(pw)
vbox.addLayout(hbox)
cb_lost = QCheckBox(_("I have lost my Google Authenticator account"))
cb_lost.setToolTip(_("Check this box to request a new secret. You will need to retype your seed."))
vbox.addWidget(cb_lost)
cb_lost.setVisible(otp_secret is None)
def set_enabled():
b = True if cb_lost.isChecked() else len(pw.text()) == 6
window.next_button.setEnabled(b)
pw.textChanged.connect(set_enabled)
cb_lost.toggled.connect(set_enabled)
window.exec_layout(vbox, next_enabled=False, raise_on_cancel=False)
self.check_otp(window, short_id, otp_secret, xpub3, pw.get_amount(), cb_lost.isChecked())
@hook
def init_wallet_wizard(self, wizard: 'QENewWalletWizard'):
# FIXME: self.so is currently scoped to plugin, which is shared among wizards. This is wrong
# refactor to be a member of the wizard instance
self.so = QSignalObject(self, wizard, None)
self.extend_wizard(wizard)
self._wizard = wizard
def extend_wizard(self, wizard: 'QENewWalletWizard'):
super().extend_wizard(wizard)
views = {
'trustedcoin_start': {
'gui': WCDisclaimer,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_choose_seed': {
'gui': WCChooseSeed,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_create_seed': {
'gui': WCCreateSeed,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_confirm_seed': {
'gui': WCConfirmSeed,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_have_seed': {
'gui': WCHaveSeed,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_keep_disable': {
'gui': WCKeepDisable,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_tos_email': {
'gui': WCTerms,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
},
'trustedcoin_show_confirm_otp': {
'gui': WCShowConfirmOTP,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
}
}
wizard.navmap_merge(views)
# modify default flow, insert seed extension entry/confirm as separate views
ext = {
'trustedcoin_create_seed': {
'next': lambda d: 'trustedcoin_create_ext' if wizard.wants_ext(d) else 'trustedcoin_confirm_seed'
},
'trustedcoin_create_ext': {
'gui': WCEnterExt,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
'next': 'trustedcoin_confirm_seed',
},
'trustedcoin_confirm_seed': {
'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_tos_email'
},
'trustedcoin_confirm_ext': {
'gui': WCConfirmExt,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
'next': 'trustedcoin_tos_email',
},
'trustedcoin_have_seed': {
'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable'
},
'trustedcoin_have_ext': {
'gui': WCEnterExt,
'params': {'icon': icon_path('trustedcoin-wizard.png')},
'next': 'trustedcoin_keep_disable',
},
}
wizard.navmap_merge(ext)
class WCDisclaimer(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Disclaimer'))
self.layout().addWidget(WWLabel('\n\n'.join(DISCLAIMER)))
self.layout().addStretch(1)
self._valid = True
def apply(self):
pass
class WCChooseSeed(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Create or restore'))
message = _('Do you want to create a new seed, or restore a wallet using an existing seed?')
choices = [
('createseed', _('Create a new seed')),
('haveseed', _('I already have a seed')),
]
self.c_values = [x[0] for x in choices]
c_titles = [x[1] for x in choices]
self.clayout = ChoicesLayout(message, c_titles)
self.layout().addLayout(self.clayout.layout())
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['keystore_type'] = self.c_values[self.clayout.selected_index()]
class WCTerms(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Terms and conditions'))
self.plugin = wizard.plugins.get_plugin('trustedcoin')
self._has_tos = False
def on_ready(self):
self.tos_e = TOS()
self.tos_e.setReadOnly(True)
self.layout().addWidget(self.tos_e)
self.layout().addWidget(QLabel(_("Please enter your e-mail address")))
self.email_e = QLineEdit()
self.email_e.textChanged.connect(self.validate)
self.layout().addWidget(self.email_e)
self.fetch_terms_and_conditions()
def fetch_terms_and_conditions(self):
self.plugin.so.busyChanged.connect(self.on_busy_changed)
self.plugin.so.termsAndConditionsRetrieved.connect(self.on_terms_retrieved)
self.plugin.so.termsAndConditionsError.connect(self.on_terms_error)
self.plugin.so.fetchTermsAndConditions()
def on_busy_changed(self):
self.busy = self.plugin.so.busy
def on_terms_retrieved(self, tos: str) -> None:
self._has_tos = True
self.tos_e.setText(tos)
self.email_e.setFocus(True)
self.validate()
def on_terms_error(self, error: str) -> None:
self.error = error
def validate(self):
if self._has_tos and self.email_e.text() != '':
self.valid = True
else:
self.valid = False
def apply(self):
self.wizard_data['2fa_email'] = self.email_e.text()
class WCShowConfirmOTP(WizardComponent):
_logger = get_logger(__name__)
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Authenticator secret'))
self.plugin = wizard.plugins.get_plugin('trustedcoin')
self._otp_verified = False
self.new_otp = QWidget()
new_otp_layout = QVBoxLayout()
scanlabel = WWLabel(_('Enter or scan into authenticator app. Then authenticate below'))
new_otp_layout.addWidget(scanlabel)
self.qr = QRCodeWidget('')
new_otp_layout.addWidget(self.qr)
self.secretlabel = WWLabel()
new_otp_layout.addWidget(self.secretlabel)
self.new_otp.setLayout(new_otp_layout)
self.exist_otp = QWidget()
exist_otp_layout = QVBoxLayout()
knownlabel = WWLabel(_('This wallet is already registered with TrustedCoin.'))
exist_otp_layout.addWidget(knownlabel)
knownsecretlabel = WWLabel(_('If you still have your OTP secret, then authenticate below to finalize wallet creation'))
exist_otp_layout.addWidget(knownsecretlabel)
self.exist_otp.setLayout(exist_otp_layout)
self.authlabelnew = WWLabel(_('Then, enter your Google Authenticator code:'))
self.authlabelexist = WWLabel(_('Google Authenticator code:'))
self.spinner = QMovie(icon_path('spinner.gif'))
self.spinner.setScaledSize(QSize(24, 24))
self.spinner.setBackgroundColor(QColor('black'))
self.spinner_l = QLabel()
self.spinner_l.setMargin(5)
self.spinner_l.setVisible(False)
self.spinner_l.setMovie(self.spinner)
self.otp_status_l = QLabel()
self.otp_status_l.setAlignment(Qt.AlignHCenter)
self.otp_status_l.setVisible(False)
self.resetlabel = WWLabel(_('If you have lost your OTP secret, click the button below to request a new secret from the server.'))
self.button = QPushButton('Request OTP secret')
self.button.clicked.connect(self.on_request_otp)
hbox = QHBoxLayout()
hbox.addWidget(self.authlabelnew)
hbox.addWidget(self.authlabelexist)
hbox.addStretch(1)
hbox.addWidget(self.spinner_l)
self.otp_e = AmountEdit(None, is_int=True)
self.otp_e.setFocus(True)
self.otp_e.setMaximumWidth(150)
self.otp_e.textEdited.connect(self.on_otp_edited)
hbox.addWidget(self.otp_e)
self.layout().addWidget(self.new_otp)
self.layout().addWidget(self.exist_otp)
self.layout().addLayout(hbox)
self.layout().addWidget(self.otp_status_l)
self.layout().addWidget(self.resetlabel)
self.layout().addWidget(self.button)
self.layout().addStretch(1)
def on_ready(self):
self.plugin.so.busyChanged.connect(self.on_busy_changed)
self.plugin.so.remoteKeyError.connect(self.on_remote_key_error)
self.plugin.so.otpSuccess.connect(self.on_otp_success)
self.plugin.so.otpError.connect(self.on_otp_error)
self.plugin.so.remoteKeyError.connect(self.on_remote_key_error)
self.plugin.so.createKeystore(self.wizard_data['2fa_email'])
def update(self):
is_new = bool(self.plugin.so.remoteKeyState != 'wallet_known')
self.new_otp.setVisible(is_new)
self.exist_otp.setVisible(not is_new)
self.authlabelnew.setVisible(is_new)
self.authlabelexist.setVisible(not is_new)
self.resetlabel.setVisible(not is_new and not self._otp_verified)
self.button.setVisible(not is_new and not self._otp_verified)
if self.plugin.so.otpSecret:
self.secretlabel.setText(self.plugin.so.otpSecret)
uri = 'otpauth://totp/Electrum 2FA %s?secret=%s&digits=6' % (
self.wizard_data['wallet_name'], self.plugin.so.otpSecret)
self.qr.setData(uri)
def on_busy_changed(self):
if not self.plugin.so._verifyingOtp:
self.busy = self.plugin.so.busy
if not self.busy:
self.update()
def on_remote_key_error(self, text):
self._logger.error(text)
self.error = text
def on_request_otp(self):
self.otp_status_l.setVisible(False)
self.plugin.so.resetOtpSecret()
self.update()
def on_otp_success(self):
self._otp_verified = True
self.otp_status_l.setText('Valid!')
self.otp_status_l.setVisible(True)
self.otp_status_l.setStyleSheet(ColorScheme.GREEN.as_stylesheet(False))
self.setEnabled(True)
self.spinner_l.setVisible(False)
self.spinner.stop()
self.valid = True
def on_otp_error(self, message):
self.otp_status_l.setText(message)
self.otp_status_l.setVisible(True)
self.otp_status_l.setStyleSheet(ColorScheme.RED.as_stylesheet(False))
self.setEnabled(True)
self.spinner_l.setVisible(False)
self.spinner.stop()
def on_otp_edited(self):
self.otp_status_l.setVisible(False)
text = self.otp_e.text()
if len(text) == 6:
# verify otp
self.plugin.so.checkOtp(self.plugin.so.shortId, int(text))
self.setEnabled(False)
self.spinner_l.setVisible(True)
self.spinner.start()
self.otp_e.setText('')
def apply(self):
pass
class WCKeepDisable(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet'))
message = ' '.join([
'You are going to restore a wallet protected with two-factor authentication.',
'Do you want to keep using two-factor authentication with this wallet,',
'or do you want to disable it, and have two master private keys in your wallet?'
])
choices = [
('keep', _('Keep')),
('disable', _('Disable')),
]
self.c_values = [x[0] for x in choices]
c_titles = [x[1] for x in choices]
self.clayout = ChoicesLayout(message, c_titles)
self.layout().addLayout(self.clayout.layout())
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['trustedcoin_keepordisable'] = self.c_values[self.clayout.selected_index()]