electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 8d5e666d30aef45dc9992e1e01d9335083cb54e8
parent 5a75ce74d775ec5ea789c546f879c7212027ba06
Author: ThomasV <thomasv@electrum.org>
Date:   Fri, 18 May 2018 18:07:52 +0200

support TrustedCoin plugin in the kivy GUI

Diffstat:
Mgui/kivy/main_window.py | 3++-
Mgui/kivy/uix/dialogs/installwizard.py | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgui/qt/installwizard.py | 3+--
Mgui/qt/main_window.py | 11+++++------
Mlib/base_wizard.py | 14+++++++-------
Mlib/util.py | 4++++
Mlib/wallet.py | 1+
Mplugins/trustedcoin/__init__.py | 2+-
Mplugins/trustedcoin/qt.py | 82+++++++++++++++++++++++++++++++++++++------------------------------------------
Mplugins/trustedcoin/trustedcoin.py | 74+++++++++++++++++++++++++++++++++++++++++++-------------------------------
10 files changed, 291 insertions(+), 92 deletions(-)

diff --git a/gui/kivy/main_window.py b/gui/kivy/main_window.py @@ -505,7 +505,7 @@ class ElectrumWindow(App): else: Logger.debug('Electrum: Wallet not found. Launching install wizard') storage = WalletStorage(path, manual_upgrades=True) - wizard = Factory.InstallWizard(self.electrum_config, storage) + wizard = Factory.InstallWizard(self.electrum_config, self.plugins, storage) wizard.bind(on_wizard_complete=self.on_wizard_complete) action = wizard.storage.get_action() wizard.run(action) @@ -823,6 +823,7 @@ class ElectrumWindow(App): except InvalidPassword: Clock.schedule_once(lambda dt: on_failure(_("Invalid PIN"))) return + on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success Clock.schedule_once(lambda dt: on_success(tx)) def _broadcast_thread(self, tx, on_complete): diff --git a/gui/kivy/uix/dialogs/installwizard.py b/gui/kivy/uix/dialogs/installwizard.py @@ -15,6 +15,7 @@ from kivy.clock import Clock from kivy.utils import platform from electrum.base_wizard import BaseWizard +from electrum.util import is_valid_email from . import EventsDialog @@ -24,6 +25,7 @@ from .password_dialog import PasswordDialog # global Variables is_test = (platform == "linux") test_seed = "time taxi field recycle tiny license olive virus report rare steel portion achieve" +test_seed = "grape impose jazz bind spatial mind jelly tourist tank today holiday stomach" test_xpub = "xpub661MyMwAqRbcEbvVtRRSjqxVnaWVUMewVzMiURAKyYratih4TtBpMypzzefmv8zUNebmNVzB3PojdC5sV2P9bDgMoo9B3SARw1MXUUfU1GL" Builder.load_string(''' @@ -171,6 +173,95 @@ Builder.load_string(''' spacing: '14dp' size_hint: 1, None +<WizardConfirmDialog> + message : '' + Widget: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + Widget + size_hint: 1, 1 + +<WizardTOSDialog> + message : '' + ScrollView: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + +<WizardEmailDialog> + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: 'Please enter your email address' + WizardTextInput: + id: email + on_text: Clock.schedule_once(root.on_text) + +<WizardKnownOTPDialog> + message : '' + message2: '' + Widget: + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + Widget + size_hint: 1, 1 + WizardTextInput: + id: otp + on_text: Clock.schedule_once(root.on_text) + Widget + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message2 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.2 + Widget + CheckBox: + id:cb + on_state: Clock.schedule_once(root.on_cb) + +<WizardNewOTPDialog> + message : '' + message2 : '' + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message + QRCodeWidget: + id: qr + size_hint: 1, 1 + Label: + color: root.text_color + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] + text: root.message2 + WizardTextInput: + id: otp + on_text: Clock.schedule_once(root.on_text) + <MButton@Button>: size_hint: 1, None height: '33dp' @@ -485,6 +576,87 @@ class WizardMultisigDialog(WizardDialog): n = self.ids.n.value return m, n +class WizardKnownOTPDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.message = _("This wallet is already registered with TrustedCoin. To finalize wallet creation, please enter your Google Authenticator Code.") + self.message2 =_("If you have lost your Google Authenticator account, check the box below to request a new secret. You will need to retype your seed.") + + def get_otp(self): + otp = self.ids.otp.text + if len(otp) != 6: + return + try: + return int(otp) + except: + return + + def get_params(self, button): + return (self.get_otp(), self.ids.cb.active) + + def on_cb(self, dt): + self.ids.otp.text = '' + self.ids.next.disabled = not self.ids.cb.active + + def on_text(self, dt): + self.ids.next.disabled = self.get_otp() is None + +class WizardNewOTPDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + otp_secret = kwargs['otp_secret'] + uri = "otpauth://totp/%s?secret=%s"%('trustedcoin.com', otp_secret) + self.message = "Please scan the following QR code in Google Authenticator. You may also use the secret key: %s"%otp_secret + self.message2 = _('Then, enter your Google Authenticator code:') + self.ids.qr.set_data(uri) + + def get_otp(self): + otp = self.ids.otp.text + if len(otp) != 6: + return + try: + return int(otp) + except: + return + + def on_text(self, dt): + self.ids.next.disabled = self.get_otp() is None + + def get_params(self, button): + return (self.get_otp(), False) + +class WizardTOSDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + WizardDialog.__init__(self, wizard, **kwargs) + self.ids.next.text = 'Accept' + self.ids.next.disabled = False + self.message = kwargs['tos'] + self.message2 = _('Enter your email address:') + +class WizardEmailDialog(WizardDialog): + def get_params(self, button): + return (self.ids.email.text,) + def on_text(self, dt): + self.ids.next.disabled = not is_valid_email(self.ids.email.text) + +class WizardConfirmDialog(WizardDialog): + + def __init__(self, wizard, **kwargs): + super(WizardConfirmDialog, self).__init__(wizard, **kwargs) + self.message = kwargs.get('message', '') + self.value = 'ok' + + def on_parent(self, instance, value): + if value: + app = App.get_running_app() + self._back = _back = partial(app.dispatch, 'on_back') + + def get_params(self, button): + return (True,) + class WizardChoiceDialog(WizardDialog): def __init__(self, wizard, **kwargs): @@ -789,6 +961,21 @@ class InstallWizard(BaseWizard, Widget): def restore_seed_dialog(self, **kwargs): RestoreSeedDialog(self, **kwargs).open() + def confirm_dialog(self, **kwargs): + WizardConfirmDialog(self, **kwargs).open() + + def tos_dialog(self, **kwargs): + WizardTOSDialog(self, **kwargs).open() + + def email_dialog(self, **kwargs): + WizardEmailDialog(self, **kwargs).open() + + def otp_dialog(self, **kwargs): + if kwargs['otp_secret']: + WizardNewOTPDialog(self, **kwargs).open() + else: + WizardKnownOTPDialog(self, **kwargs).open() + def add_xpub_dialog(self, **kwargs): kwargs['message'] += ' ' + _('Use the camera button to scan a QR code.') AddXpubDialog(self, **kwargs).open() @@ -800,6 +987,8 @@ class InstallWizard(BaseWizard, Widget): def show_xpub_dialog(self, **kwargs): ShowXpubDialog(self, **kwargs).open() + def show_message(self, msg): self.show_error(msg) + def show_error(self, msg): app = App.get_running_app() Clock.schedule_once(lambda dt: app.show_error(msg)) diff --git a/gui/qt/installwizard.py b/gui/qt/installwizard.py @@ -92,13 +92,12 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): synchronized_signal = pyqtSignal(str) def __init__(self, config, app, plugins, storage): - BaseWizard.__init__(self, config, storage) + BaseWizard.__init__(self, config, plugins, storage) QDialog.__init__(self, None) self.setWindowTitle('Electrum - ' + _('Install Wizard')) self.app = app self.config = config # Set for base base class - self.plugins = plugins self.language_for_seed = config.get('language') self.setMinimumSize(600, 400) self.accept_signal.connect(self.accept) diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py @@ -1577,20 +1577,19 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): '''Sign the transaction in a separate thread. When done, calls the callback with a success code of True or False. ''' - - def on_signed(result): + def on_success(result): callback(True) - def on_failed(exc_info): + def on_failure(exc_info): self.on_error(exc_info) callback(False) - + on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success if self.tx_external_keypairs: # can sign directly task = partial(Transaction.sign, tx, self.tx_external_keypairs) else: task = partial(self.wallet.sign_transaction, tx, password) - WaitingDialog(self, _('Signing transaction...'), task, - on_signed, on_failed) + msg = _('Signing transaction...') + WaitingDialog(self, msg, task, on_success, on_failure) def broadcast_transaction(self, tx, tx_desc): diff --git a/lib/base_wizard.py b/lib/base_wizard.py @@ -48,9 +48,10 @@ class GoBack(Exception): pass class BaseWizard(object): - def __init__(self, config, storage): + def __init__(self, config, plugins, storage): super(BaseWizard, self).__init__() self.config = config + self.plugins = plugins self.storage = storage self.wallet = None self.stack = [] @@ -59,6 +60,9 @@ class BaseWizard(object): self.is_kivy = config.get('gui') == 'kivy' self.seed_type = None + def set_icon(self, icon): + pass + def run(self, *args): action = args[0] args = args[1:] @@ -369,12 +373,8 @@ class BaseWizard(object): elif self.seed_type == 'old': self.run('create_keystore', seed, '') elif self.seed_type == '2fa': - if self.is_kivy: - self.show_error(_('2FA seeds are not supported in this version')) - self.run('restore_from_seed') - else: - self.load_2fa() - self.run('on_restore_seed', seed, is_ext) + self.load_2fa() + self.run('on_restore_seed', seed, is_ext) else: raise Exception('Unknown seed type', self.seed_type) diff --git a/lib/util.py b/lib/util.py @@ -446,6 +446,10 @@ def user_dir(): #raise Exception("No home directory found in environment variables.") return +def is_valid_email(s): + regexp = r"[^@]+@[^@]+\.[^@]+" + return re.match(regexp, s) is not None + def format_satoshis_plain(x, decimal_point = 8): """Display a satoshi amount scaled. Always uses a '.' as a decimal diff --git a/lib/wallet.py b/lib/wallet.py @@ -1513,6 +1513,7 @@ class Abstract_Wallet(PrintError): k.sign_transaction(tx, password) except UserCancelled: continue + return tx def get_unused_addresses(self): # fixme: use slots from expired requests diff --git a/plugins/trustedcoin/__init__.py b/plugins/trustedcoin/__init__.py @@ -8,4 +8,4 @@ description = ''.join([ ]) requires_wallet_type = ['2fa'] registers_wallet_type = '2fa' -available_for = ['qt', 'cmdline'] +available_for = ['qt', 'cmdline', 'kivy'] diff --git a/plugins/trustedcoin/qt.py b/plugins/trustedcoin/qt.py @@ -38,7 +38,7 @@ from electrum_gui.qt.amountedit import AmountEdit from electrum_gui.qt.main_window import StatusBarButton from electrum.i18n import _ from electrum.plugins import hook -from electrum.util import PrintError +from electrum.util import PrintError, is_valid_email from .trustedcoin import TrustedCoinPlugin, server @@ -48,36 +48,28 @@ class TOS(QTextEdit): class HandlerTwoFactor(QObject, PrintError): - otp_start_signal = pyqtSignal(object, object) def __init__(self, plugin, window): super().__init__() self.plugin = plugin self.window = window - self.otp_start_signal.connect(self._prompt_user_for_otp) - self.otp_done = threading.Event() - def prompt_user_for_otp(self, wallet, tx): - self.otp_done.clear() - self.otp_start_signal.emit(wallet, tx) - self.otp_done.wait() - - def _prompt_user_for_otp(self, wallet, tx): + 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/'].get_tx_derivations(tx): + self.print_error("twofactor: xpub3 not needed") + return + window = self.window.top_level_window() + auth_code = self.plugin.auth_dialog(window) try: - window = self.window.top_level_window() - if not isinstance(wallet, self.plugin.wallet_class): - return - if not wallet.can_sign_without_server(): - self.print_error("twofactor:sign_tx") - auth_code = None - if wallet.keystores['x3/'].get_tx_derivations(tx): - auth_code = self.plugin.auth_dialog(window) - else: - self.print_error("twofactor: xpub3 not needed") - wallet.auth_code = auth_code - finally: - self.otp_done.set() - + wallet.on_otp(tx, auth_code) + except: + on_failure(sys.exc_info()) + return + on_success(tx) class Plugin(TrustedCoinPlugin): @@ -123,8 +115,8 @@ class Plugin(TrustedCoinPlugin): return return pw.get_amount() - def prompt_user_for_otp(self, wallet, tx): - wallet.handler_2fa.prompt_user_for_otp(wallet, tx) + 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(self, window, on_finished=None): task = partial(self.request_billing_info, window.wallet) @@ -145,7 +137,6 @@ class Plugin(TrustedCoinPlugin): return True return False - def settings_dialog(self, window): self.waiting_dialog(window, partial(self.show_settings_dialog, window)) @@ -216,6 +207,20 @@ class Plugin(TrustedCoinPlugin): window.message_e.setFrozen(True) window.amount_e.setFrozen(True) + def go_online_dialog(self, wizard): + msg = [ + _("Your wallet file is: {}.").format(os.path.abspath(wizard.storage.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.stack = [] + wizard.confirm_dialog(title='', message=msg, run_next = lambda x: wizard.run('accept_terms_of_use')) + def accept_terms_of_use(self, window): vbox = QVBoxLayout() vbox.addWidget(QLabel(_("Terms of Service"))) @@ -256,24 +261,21 @@ class Plugin(TrustedCoinPlugin): window.terminate() def set_enabled(): - valid_email = re.match(regexp, email_e.text()) is not None - next_button.setEnabled(tos_received and valid_email) + 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 = Thread(target=request_TOS) t.setDaemon(True) t.start() - - regexp = r"[^@]+@[^@]+\.[^@]+" email_e.textChanged.connect(set_enabled) email_e.setFocus(True) - window.exec_layout(vbox, next_enabled=False) next_button.setText(prior_button_text) - return str(email_e.text()) + email = str(email_e.text()) + self.create_remote_key(email, window) - def request_otp_dialog(self, window, _id, otp_secret): + 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) @@ -291,7 +293,6 @@ class Plugin(TrustedCoinPlugin): label.setWordWrap(1) vbox.addWidget(label) msg = _('Google Authenticator code:') - hbox = QHBoxLayout() hbox.addWidget(WWLabel(msg)) pw = AmountEdit(None, is_int = True) @@ -299,21 +300,14 @@ class Plugin(TrustedCoinPlugin): 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) - return pw.get_amount(), cb_lost.isChecked() - - + 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()) diff --git a/plugins/trustedcoin/trustedcoin.py b/plugins/trustedcoin/trustedcoin.py @@ -75,6 +75,18 @@ 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."), ] + +KIVY_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. " + "The third key is stored on a remote server that signs transactions on " + "your behalf.A small fee will be charged on each transaction that uses the " + "remote server."), + _("Note that your coins are not locked in this service. You may withdraw " + "your funds at any time and at no cost, without the remote server, by " + "using the 'restore wallet' option with your wallet seed."), +] RESTORE_MSG = _("Enter the seed for your 2-factor wallet:") class TrustedCoinException(Exception): @@ -215,7 +227,6 @@ class Wallet_2fa(Multisig_Wallet): Deterministic_Wallet.__init__(self, storage) self.is_billing = False self.billing_info = None - self.auth_code = None def can_sign_without_server(self): return not self.keystores['x2/'].is_watching_only() @@ -269,25 +280,22 @@ class Wallet_2fa(Multisig_Wallet): tx = mk_tx(outputs) return tx - def sign_transaction(self, tx, password): - Multisig_Wallet.sign_transaction(self, tx, password) - if tx.is_complete(): - return - self.plugin.prompt_user_for_otp(self, tx) - if not self.auth_code: + def on_otp(self, tx, otp): + if not otp: self.print_error("sign_transaction: no auth code") return + otp = int(otp) long_user_id, short_id = self.get_user_id() tx_dict = tx.as_dict() raw_tx = tx_dict["hex"] - r = server.sign(short_id, raw_tx, self.auth_code) + r = server.sign(short_id, raw_tx, otp) if r: raw_tx = r.get('transaction') tx.update(raw_tx) self.print_error("twofactor: is complete", tx.is_complete()) # reset billing_info self.billing_info = None - self.auth_code = None + # Utility functions @@ -316,6 +324,7 @@ def make_billing_address(wallet, num): class TrustedCoinPlugin(BasePlugin): wallet_class = Wallet_2fa + disclaimer_msg = DISCLAIMER def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) @@ -336,6 +345,21 @@ class TrustedCoinPlugin(BasePlugin): return False @hook + def tc_sign_wrapper(self, wallet, tx, on_success, on_failure): + if not isinstance(wallet, self.wallet_class): + return + if tx.is_complete(): + return + if wallet.can_sign_without_server(): + return + if not wallet.keystores['x3/'].get_tx_derivations(tx): + self.print_error("twofactor: xpub3 not needed") + return + def wrapper(tx): + self.prompt_user_for_otp(wallet, tx, on_success, on_failure) + return wrapper + + @hook def get_tx_extra_fee(self, wallet, tx): if type(wallet) != Wallet_2fa: return @@ -391,7 +415,7 @@ class TrustedCoinPlugin(BasePlugin): def show_disclaimer(self, wizard): wizard.set_icon(':icons/trustedcoin-wizard.png') wizard.stack = [] - wizard.confirm_dialog(title='Disclaimer', message='\n\n'.join(DISCLAIMER), run_next = lambda x: wizard.run('choose_seed')) + wizard.confirm_dialog(title='Disclaimer', message='\n\n'.join(self.disclaimer_msg), run_next = lambda x: wizard.run('choose_seed')) def choose_seed(self, wizard): title = _('Create or restore') @@ -450,18 +474,7 @@ class TrustedCoinPlugin(BasePlugin): wizard.storage.put('x1/', k1.dump()) wizard.storage.put('x2/', k2.dump()) wizard.storage.write() - msg = [ - _("Your wallet file is: {}.").format(os.path.abspath(wizard.storage.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.stack = [] - wizard.confirm_dialog(title='', message=msg, run_next = lambda x: wizard.run('create_remote_key')) + self.go_online_dialog(wizard) def restore_wallet(self, wizard): wizard.opt_bip39 = False @@ -516,8 +529,8 @@ class TrustedCoinPlugin(BasePlugin): wizard.wallet = Wallet_2fa(storage) wizard.create_addresses() - def create_remote_key(self, wizard): - email = self.accept_terms_of_use(wizard) + + def create_remote_key(self, email, wizard): xpub1 = wizard.storage.get('x1/')['xpub'] xpub2 = wizard.storage.get('x2/')['xpub'] # Generate third key deterministically. @@ -550,10 +563,9 @@ class TrustedCoinPlugin(BasePlugin): except Exception as e: wizard.show_message(str(e)) return - self.check_otp(wizard, short_id, otp_secret, xpub3) + self.request_otp_dialog(wizard, short_id, otp_secret, xpub3) - def check_otp(self, wizard, short_id, otp_secret, xpub3): - otp, reset = self.request_otp_dialog(wizard, short_id, otp_secret) + def check_otp(self, wizard, short_id, otp_secret, xpub3, otp, reset): if otp: self.do_auth(wizard, short_id, otp, xpub3) elif reset: @@ -569,8 +581,8 @@ class TrustedCoinPlugin(BasePlugin): def do_auth(self, wizard, short_id, otp, xpub3): try: server.auth(short_id, otp) - except: - wizard.show_message(_('Incorrect password')) + except Exception as e: + wizard.show_message(str(e)) return k3 = keystore.from_xpub(xpub3) wizard.storage.put('x3/', k3.dump()) @@ -603,7 +615,7 @@ class TrustedCoinPlugin(BasePlugin): if not new_secret: wizard.show_message(_('Request rejected by server')) return - self.check_otp(wizard, short_id, new_secret, xpub3) + self.request_otp_dialog(wizard, short_id, new_secret, xpub3) @hook def get_action(self, storage): @@ -614,4 +626,4 @@ class TrustedCoinPlugin(BasePlugin): if not storage.get('x2/'): return self, 'show_disclaimer' if not storage.get('x3/'): - return self, 'create_remote_key' + return self, 'accept_terms_of_use'