1#!/usr/local/bin/python3.8
2#
3# Electrum - Lightweight Bitcoin Client
4# Copyright (C) 2015 Thomas Voegtlin
5#
6# Permission is hereby granted, free of charge, to any person
7# obtaining a copy of this software and associated documentation files
8# (the "Software"), to deal in the Software without restriction,
9# including without limitation the rights to use, copy, modify, merge,
10# publish, distribute, sublicense, and/or sell copies of the Software,
11# and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be
15# included in all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
25import asyncio
26import socket
27import json
28import base64
29import time
30import hashlib
31from collections import defaultdict
32from typing import Dict, Union, Sequence, List
33
34from urllib.parse import urljoin
35from urllib.parse import quote
36from aiohttp import ClientResponse
37
38from electrum import ecc, constants, keystore, version, bip32, bitcoin
39from electrum.bip32 import BIP32Node, xpub_type
40from electrum.crypto import sha256
41from electrum.transaction import PartialTxOutput, PartialTxInput, PartialTransaction, Transaction
42from electrum.mnemonic import Mnemonic, seed_type, is_any_2fa_seed_type
43from electrum.wallet import Multisig_Wallet, Deterministic_Wallet
44from electrum.i18n import _
45from electrum.plugin import BasePlugin, hook
46from electrum.util import NotEnoughFunds, UserFacingException
47from electrum.storage import StorageEncryptionVersion
48from electrum.network import Network
49from electrum.base_wizard import BaseWizard, WizardWalletPasswordSetting
50from electrum.logging import Logger
51
52
53def get_signing_xpub(xtype):
54    if not constants.net.TESTNET:
55        xpub = "xpub661MyMwAqRbcGnMkaTx2594P9EDuiEqMq25PM2aeG6UmwzaohgA6uDmNsvSUV8ubqwA3Wpste1hg69XHgjUuCD5HLcEp2QPzyV1HMrPppsL"
56    else:
57        xpub = "tpubD6NzVbkrYhZ4XdmyJQcCPjQfg6RXVUzGFhPjZ7uvRC8JLcS7Hw1i7UTpyhp9grHpak4TyK2hzBJrujDVLXQ6qB5tNpVx9rC6ixijUXadnmY"
58    if xtype not in ('standard', 'p2wsh'):
59        raise NotImplementedError('xtype: {}'.format(xtype))
60    if xtype == 'standard':
61        return xpub
62    node = BIP32Node.from_xkey(xpub)
63    return node._replace(xtype=xtype).to_xpub()
64
65def get_billing_xpub():
66    if constants.net.TESTNET:
67        return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r"
68    else:
69        return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU"
70
71
72DISCLAIMER = [
73    _("Two-factor authentication is a service provided by TrustedCoin.  "
74      "It uses a multi-signature wallet, where you own 2 of 3 keys.  "
75      "The third key is stored on a remote server that signs transactions on "
76      "your behalf.  To use this service, you will need a smartphone with "
77      "Google Authenticator installed."),
78    _("A small fee will be charged on each transaction that uses the "
79      "remote server.  You may check and modify your billing preferences "
80      "once the installation is complete."),
81    _("Note that your coins are not locked in this service.  You may withdraw "
82      "your funds at any time and at no cost, without the remote server, by "
83      "using the 'restore wallet' option with your wallet seed."),
84    _("The next step will generate the seed of your wallet.  This seed will "
85      "NOT be saved in your computer, and it must be stored on paper.  "
86      "To be safe from malware, you may want to do this on an offline "
87      "computer, and move your wallet later to an online computer."),
88]
89
90KIVY_DISCLAIMER = [
91    _("Two-factor authentication is a service provided by TrustedCoin. "
92      "To use it, you must have a separate device with Google Authenticator."),
93    _("This service uses a multi-signature wallet, where you own 2 of 3 keys.  "
94      "The third key is stored on a remote server that signs transactions on "
95      "your behalf. A small fee will be charged on each transaction that uses the "
96      "remote server."),
97    _("Note that your coins are not locked in this service.  You may withdraw "
98      "your funds at any time and at no cost, without the remote server, by "
99      "using the 'restore wallet' option with your wallet seed."),
100]
101RESTORE_MSG = _("Enter the seed for your 2-factor wallet:")
102
103class TrustedCoinException(Exception):
104    def __init__(self, message, status_code=0):
105        Exception.__init__(self, message)
106        self.status_code = status_code
107
108
109class ErrorConnectingServer(Exception):
110    def __init__(self, reason: Union[str, Exception] = None):
111        self.reason = reason
112
113    def __str__(self):
114        header = _("Error connecting to {} server").format('TrustedCoin')
115        reason = self.reason
116        if isinstance(reason, BaseException):
117            reason = repr(reason)
118        return f"{header}:\n{reason}" if reason else header
119
120
121class TrustedCoinCosignerClient(Logger):
122    def __init__(self, user_agent=None, base_url='https://api.trustedcoin.com/2/'):
123        self.base_url = base_url
124        self.debug = False
125        self.user_agent = user_agent
126        Logger.__init__(self)
127
128    async def handle_response(self, resp: ClientResponse):
129        if resp.status != 200:
130            try:
131                r = await resp.json()
132                message = r['message']
133            except:
134                message = await resp.text()
135            raise TrustedCoinException(message, resp.status)
136        try:
137            return await resp.json()
138        except:
139            return await resp.text()
140
141    def send_request(self, method, relative_url, data=None, *, timeout=None):
142        network = Network.get_instance()
143        if not network:
144            raise ErrorConnectingServer('You are offline.')
145        url = urljoin(self.base_url, relative_url)
146        if self.debug:
147            self.logger.debug(f'<-- {method} {url} {data}')
148        headers = {}
149        if self.user_agent:
150            headers['user-agent'] = self.user_agent
151        try:
152            if method == 'get':
153                response = Network.send_http_on_proxy(method, url,
154                                                      params=data,
155                                                      headers=headers,
156                                                      on_finish=self.handle_response,
157                                                      timeout=timeout)
158            elif method == 'post':
159                response = Network.send_http_on_proxy(method, url,
160                                                      json=data,
161                                                      headers=headers,
162                                                      on_finish=self.handle_response,
163                                                      timeout=timeout)
164            else:
165                assert False
166        except TrustedCoinException:
167            raise
168        except Exception as e:
169            raise ErrorConnectingServer(e)
170        else:
171            if self.debug:
172                self.logger.debug(f'--> {response}')
173            return response
174
175    def get_terms_of_service(self, billing_plan='electrum-per-tx-otp'):
176        """
177        Returns the TOS for the given billing plan as a plain/text unicode string.
178        :param billing_plan: the plan to return the terms for
179        """
180        payload = {'billing_plan': billing_plan}
181        return self.send_request('get', 'tos', payload)
182
183    def create(self, xpubkey1, xpubkey2, email, billing_plan='electrum-per-tx-otp'):
184        """
185        Creates a new cosigner resource.
186        :param xpubkey1: a bip32 extended public key (customarily the hot key)
187        :param xpubkey2: a bip32 extended public key (customarily the cold key)
188        :param email: a contact email
189        :param billing_plan: the billing plan for the cosigner
190        """
191        payload = {
192            'email': email,
193            'xpubkey1': xpubkey1,
194            'xpubkey2': xpubkey2,
195            'billing_plan': billing_plan,
196        }
197        return self.send_request('post', 'cosigner', payload)
198
199    def auth(self, id, otp):
200        """
201        Attempt to authenticate for a particular cosigner.
202        :param id: the id of the cosigner
203        :param otp: the one time password
204        """
205        payload = {'otp': otp}
206        return self.send_request('post', 'cosigner/%s/auth' % quote(id), payload)
207
208    def get(self, id):
209        """ Get billing info """
210        return self.send_request('get', 'cosigner/%s' % quote(id))
211
212    def get_challenge(self, id):
213        """ Get challenge to reset Google Auth secret """
214        return self.send_request('get', 'cosigner/%s/otp_secret' % quote(id))
215
216    def reset_auth(self, id, challenge, signatures):
217        """ Reset Google Auth secret """
218        payload = {'challenge':challenge, 'signatures':signatures}
219        return self.send_request('post', 'cosigner/%s/otp_secret' % quote(id), payload)
220
221    def sign(self, id, transaction, otp):
222        """
223        Attempt to authenticate for a particular cosigner.
224        :param id: the id of the cosigner
225        :param transaction: the hex encoded [partially signed] compact transaction to sign
226        :param otp: the one time password
227        """
228        payload = {
229            'otp': otp,
230            'transaction': transaction
231        }
232        return self.send_request('post', 'cosigner/%s/sign' % quote(id), payload,
233                                 timeout=60)
234
235    def transfer_credit(self, id, recipient, otp, signature_callback):
236        """
237        Transfer a cosigner's credits to another cosigner.
238        :param id: the id of the sending cosigner
239        :param recipient: the id of the recipient cosigner
240        :param otp: the one time password (of the sender)
241        :param signature_callback: a callback that signs a text message using xpubkey1/0/0 returning a compact sig
242        """
243        payload = {
244            'otp': otp,
245            'recipient': recipient,
246            'timestamp': int(time.time()),
247
248        }
249        relative_url = 'cosigner/%s/transfer' % quote(id)
250        full_url = urljoin(self.base_url, relative_url)
251        headers = {
252            'x-signature': signature_callback(full_url + '\n' + json.dumps(payload))
253        }
254        return self.send_request('post', relative_url, payload, headers)
255
256
257server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION)
258
259class Wallet_2fa(Multisig_Wallet):
260
261    plugin: 'TrustedCoinPlugin'
262
263    wallet_type = '2fa'
264
265    def __init__(self, db, storage, *, config):
266        self.m, self.n = 2, 3
267        Deterministic_Wallet.__init__(self, db, storage, config=config)
268        self.is_billing = False
269        self.billing_info = None
270        self._load_billing_addresses()
271
272    def _load_billing_addresses(self):
273        billing_addresses = {
274            'legacy': self.db.get('trustedcoin_billing_addresses', {}),
275            'segwit': self.db.get('trustedcoin_billing_addresses_segwit', {})
276        }
277        self._billing_addresses = {}  # type: Dict[str, Dict[int, str]]  # addr_type -> index -> addr
278        self._billing_addresses_set = set()  # set of addrs
279        for addr_type, d in list(billing_addresses.items()):
280            self._billing_addresses[addr_type] = {}
281            # convert keys from str to int
282            for index, addr in d.items():
283                self._billing_addresses[addr_type][int(index)] = addr
284                self._billing_addresses_set.add(addr)
285
286    def can_sign_without_server(self):
287        return not self.keystores['x2/'].is_watching_only()
288
289    def get_user_id(self):
290        return get_user_id(self.db)
291
292    def min_prepay(self):
293        return min(self.price_per_tx.keys())
294
295    def num_prepay(self):
296        default = self.min_prepay()
297        n = self.config.get('trustedcoin_prepay', default)
298        if n not in self.price_per_tx:
299            n = default
300        return n
301
302    def extra_fee(self):
303        if self.can_sign_without_server():
304            return 0
305        if self.billing_info is None:
306            self.plugin.start_request_thread(self)
307            return 0
308        if self.billing_info.get('tx_remaining'):
309            return 0
310        if self.is_billing:
311            return 0
312        n = self.num_prepay()
313        price = int(self.price_per_tx[n])
314        if price > 100000 * n:
315            raise Exception('too high trustedcoin fee ({} for {} txns)'.format(price, n))
316        return price
317
318    def make_unsigned_transaction(
319            self, *,
320            coins: Sequence[PartialTxInput],
321            outputs: List[PartialTxOutput],
322            fee=None,
323            change_addr: str = None,
324            is_sweep=False,
325            rbf=False) -> PartialTransaction:
326
327        mk_tx = lambda o: Multisig_Wallet.make_unsigned_transaction(
328            self, coins=coins, outputs=o, fee=fee, change_addr=change_addr, rbf=rbf)
329        extra_fee = self.extra_fee() if not is_sweep else 0
330        if extra_fee:
331            address = self.billing_info['billing_address_segwit']
332            fee_output = PartialTxOutput.from_address_and_value(address, extra_fee)
333            try:
334                tx = mk_tx(outputs + [fee_output])
335            except NotEnoughFunds:
336                # TrustedCoin won't charge if the total inputs is
337                # lower than their fee
338                tx = mk_tx(outputs)
339                if tx.input_value() >= extra_fee:
340                    raise
341                self.logger.info("not charging for this tx")
342        else:
343            tx = mk_tx(outputs)
344        return tx
345
346    def on_otp(self, tx: PartialTransaction, otp):
347        if not otp:
348            self.logger.info("sign_transaction: no auth code")
349            return
350        otp = int(otp)
351        long_user_id, short_id = self.get_user_id()
352        raw_tx = tx.serialize_as_bytes().hex()
353        assert raw_tx[:10] == "70736274ff", f"bad magic. {raw_tx[:10]}"
354        try:
355            r = server.sign(short_id, raw_tx, otp)
356        except TrustedCoinException as e:
357            if e.status_code == 400:  # invalid OTP
358                raise UserFacingException(_('Invalid one-time password.')) from e
359            else:
360                raise
361        if r:
362            received_raw_tx = r.get('transaction')
363            received_tx = Transaction(received_raw_tx)
364            tx.combine_with_other_psbt(received_tx)
365        self.logger.info(f"twofactor: is complete {tx.is_complete()}")
366        # reset billing_info
367        self.billing_info = None
368        self.plugin.start_request_thread(self)
369
370    def add_new_billing_address(self, billing_index: int, address: str, addr_type: str):
371        billing_addresses_of_this_type = self._billing_addresses[addr_type]
372        saved_addr = billing_addresses_of_this_type.get(billing_index)
373        if saved_addr is not None:
374            if saved_addr == address:
375                return  # already saved this address
376            else:
377                raise Exception('trustedcoin billing address inconsistency.. '
378                                'for index {}, already saved {}, now got {}'
379                                .format(billing_index, saved_addr, address))
380        # do we have all prior indices? (are we synced?)
381        largest_index_we_have = max(billing_addresses_of_this_type) if billing_addresses_of_this_type else -1
382        if largest_index_we_have + 1 < billing_index:  # need to sync
383            for i in range(largest_index_we_have + 1, billing_index):
384                addr = make_billing_address(self, i, addr_type=addr_type)
385                billing_addresses_of_this_type[i] = addr
386                self._billing_addresses_set.add(addr)
387        # save this address; and persist to disk
388        billing_addresses_of_this_type[billing_index] = address
389        self._billing_addresses_set.add(address)
390        self._billing_addresses[addr_type] = billing_addresses_of_this_type
391        self.db.put('trustedcoin_billing_addresses', self._billing_addresses['legacy'])
392        self.db.put('trustedcoin_billing_addresses_segwit', self._billing_addresses['segwit'])
393        # FIXME this often runs in a daemon thread, where storage.write will fail
394        self.db.write(self.storage)
395
396    def is_billing_address(self, addr: str) -> bool:
397        return addr in self._billing_addresses_set
398
399
400# Utility functions
401
402def get_user_id(db):
403    def make_long_id(xpub_hot, xpub_cold):
404        return sha256(''.join(sorted([xpub_hot, xpub_cold])))
405    xpub1 = db.get('x1/')['xpub']
406    xpub2 = db.get('x2/')['xpub']
407    long_id = make_long_id(xpub1, xpub2)
408    short_id = hashlib.sha256(long_id).hexdigest()
409    return long_id, short_id
410
411def make_xpub(xpub, s) -> str:
412    rootnode = BIP32Node.from_xkey(xpub)
413    child_pubkey, child_chaincode = bip32._CKD_pub(parent_pubkey=rootnode.eckey.get_public_key_bytes(compressed=True),
414                                                   parent_chaincode=rootnode.chaincode,
415                                                   child_index=s)
416    child_node = BIP32Node(xtype=rootnode.xtype,
417                           eckey=ecc.ECPubkey(child_pubkey),
418                           chaincode=child_chaincode)
419    return child_node.to_xpub()
420
421def make_billing_address(wallet, num, addr_type):
422    long_id, short_id = wallet.get_user_id()
423    xpub = make_xpub(get_billing_xpub(), long_id)
424    usernode = BIP32Node.from_xkey(xpub)
425    child_node = usernode.subkey_at_public_derivation([num])
426    pubkey = child_node.eckey.get_public_key_bytes(compressed=True)
427    if addr_type == 'legacy':
428        return bitcoin.public_key_to_p2pkh(pubkey)
429    elif addr_type == 'segwit':
430        return bitcoin.public_key_to_p2wpkh(pubkey)
431    else:
432        raise ValueError(f'unexpected billing type: {addr_type}')
433
434
435class TrustedCoinPlugin(BasePlugin):
436    wallet_class = Wallet_2fa
437    disclaimer_msg = DISCLAIMER
438
439    def __init__(self, parent, config, name):
440        BasePlugin.__init__(self, parent, config, name)
441        self.wallet_class.plugin = self
442        self.requesting = False
443
444    @staticmethod
445    def is_valid_seed(seed):
446        t = seed_type(seed)
447        return is_any_2fa_seed_type(t)
448
449    def is_available(self):
450        return True
451
452    def is_enabled(self):
453        return True
454
455    def can_user_disable(self):
456        return False
457
458    @hook
459    def tc_sign_wrapper(self, wallet, tx, on_success, on_failure):
460        if not isinstance(wallet, self.wallet_class):
461            return
462        if tx.is_complete():
463            return
464        if wallet.can_sign_without_server():
465            return
466        if not wallet.keystores['x3/'].can_sign(tx, ignore_watching_only=True):
467            self.logger.info("twofactor: xpub3 not needed")
468            return
469        def wrapper(tx):
470            assert tx
471            self.prompt_user_for_otp(wallet, tx, on_success, on_failure)
472        return wrapper
473
474    def prompt_user_for_otp(self, wallet, tx, on_success, on_failure) -> None:
475        raise NotImplementedError()
476
477    @hook
478    def get_tx_extra_fee(self, wallet, tx: Transaction):
479        if type(wallet) != Wallet_2fa:
480            return
481        for o in tx.outputs():
482            if wallet.is_billing_address(o.address):
483                return o.address, o.value
484
485    def finish_requesting(func):
486        def f(self, *args, **kwargs):
487            try:
488                return func(self, *args, **kwargs)
489            finally:
490                self.requesting = False
491        return f
492
493    @finish_requesting
494    def request_billing_info(self, wallet: 'Wallet_2fa', *, suppress_connection_error=True):
495        if wallet.can_sign_without_server():
496            return
497        self.logger.info("request billing info")
498        try:
499            billing_info = server.get(wallet.get_user_id()[1])
500        except ErrorConnectingServer as e:
501            if suppress_connection_error:
502                self.logger.info(repr(e))
503                return
504            raise
505        billing_index = billing_info['billing_index']
506        # add segwit billing address; this will be used for actual billing
507        billing_address = make_billing_address(wallet, billing_index, addr_type='segwit')
508        if billing_address != billing_info['billing_address_segwit']:
509            raise Exception(f'unexpected trustedcoin billing address: '
510                            f'calculated {billing_address}, received {billing_info["billing_address_segwit"]}')
511        wallet.add_new_billing_address(billing_index, billing_address, addr_type='segwit')
512        # also add legacy billing address; only used for detecting past payments in GUI
513        billing_address = make_billing_address(wallet, billing_index, addr_type='legacy')
514        wallet.add_new_billing_address(billing_index, billing_address, addr_type='legacy')
515
516        wallet.billing_info = billing_info
517        wallet.price_per_tx = dict(billing_info['price_per_tx'])
518        wallet.price_per_tx.pop(1, None)
519        return True
520
521    def start_request_thread(self, wallet):
522        from threading import Thread
523        if self.requesting is False:
524            self.requesting = True
525            t = Thread(target=self.request_billing_info, args=(wallet,))
526            t.setDaemon(True)
527            t.start()
528            return t
529
530    def make_seed(self, seed_type):
531        if not is_any_2fa_seed_type(seed_type):
532            raise Exception(f'unexpected seed type: {seed_type}')
533        return Mnemonic('english').make_seed(seed_type=seed_type)
534
535    @hook
536    def do_clear(self, window):
537        window.wallet.is_billing = False
538
539    def show_disclaimer(self, wizard: BaseWizard):
540        wizard.set_icon('trustedcoin-wizard.png')
541        wizard.reset_stack()
542        wizard.confirm_dialog(title='Disclaimer', message='\n\n'.join(self.disclaimer_msg), run_next = lambda x: wizard.run('choose_seed'))
543
544    def choose_seed(self, wizard):
545        title = _('Create or restore')
546        message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?')
547        choices = [
548            ('choose_seed_type', _('Create a new seed')),
549            ('restore_wallet', _('I already have a seed')),
550        ]
551        wizard.choice_dialog(title=title, message=message, choices=choices, run_next=wizard.run)
552
553    def choose_seed_type(self, wizard):
554        seed_type = '2fa' if self.config.get('nosegwit') else '2fa_segwit'
555        self.create_seed(wizard, seed_type)
556
557    def create_seed(self, wizard, seed_type):
558        seed = self.make_seed(seed_type)
559        f = lambda x: wizard.request_passphrase(seed, x)
560        wizard.opt_bip39 = False
561        wizard.opt_ext = True
562        wizard.show_seed_dialog(run_next=f, seed_text=seed)
563
564    @classmethod
565    def get_xkeys(self, seed, t, passphrase, derivation):
566        assert is_any_2fa_seed_type(t)
567        xtype = 'standard' if t == '2fa' else 'p2wsh'
568        bip32_seed = Mnemonic.mnemonic_to_seed(seed, passphrase)
569        rootnode = BIP32Node.from_rootseed(bip32_seed, xtype=xtype)
570        child_node = rootnode.subkey_at_private_derivation(derivation)
571        return child_node.to_xprv(), child_node.to_xpub()
572
573    @classmethod
574    def xkeys_from_seed(self, seed, passphrase):
575        t = seed_type(seed)
576        if not is_any_2fa_seed_type(t):
577            raise Exception(f'unexpected seed type: {t}')
578        words = seed.split()
579        n = len(words)
580        if t == '2fa':
581            if n >= 20:  # old scheme
582                # note: pre-2.7 2fa seeds were typically 24-25 words, however they
583                # could probabilistically be arbitrarily shorter due to a bug. (see #3611)
584                # the probability of it being < 20 words is about 2^(-(256+12-19*11)) = 2^(-59)
585                if passphrase != '':
586                    raise Exception('old 2fa seed cannot have passphrase')
587                xprv1, xpub1 = self.get_xkeys(' '.join(words[0:12]), t, '', "m/")
588                xprv2, xpub2 = self.get_xkeys(' '.join(words[12:]), t, '', "m/")
589            elif n == 12:  # new scheme
590                xprv1, xpub1 = self.get_xkeys(seed, t, passphrase, "m/0'/")
591                xprv2, xpub2 = self.get_xkeys(seed, t, passphrase, "m/1'/")
592            else:
593                raise Exception(f'unrecognized seed length for "2fa" seed: {n}')
594        elif t == '2fa_segwit':
595            xprv1, xpub1 = self.get_xkeys(seed, t, passphrase, "m/0'/")
596            xprv2, xpub2 = self.get_xkeys(seed, t, passphrase, "m/1'/")
597        else:
598            raise Exception(f'unexpected seed type: {t}')
599        return xprv1, xpub1, xprv2, xpub2
600
601    def create_keystore(self, wizard, seed, passphrase):
602        # this overloads the wizard's method
603        xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
604        k1 = keystore.from_xprv(xprv1)
605        k2 = keystore.from_xpub(xpub2)
606        wizard.request_password(run_next=lambda pw, encrypt: self.on_password(wizard, pw, encrypt, k1, k2))
607
608    def on_password(self, wizard, password, encrypt_storage, k1, k2):
609        k1.update_password(None, password)
610        wizard.data['x1/'] = k1.dump()
611        wizard.data['x2/'] = k2.dump()
612        wizard.pw_args = WizardWalletPasswordSetting(password=password,
613                                                     encrypt_storage=encrypt_storage,
614                                                     storage_enc_version=StorageEncryptionVersion.USER_PASSWORD,
615                                                     encrypt_keystore=bool(password))
616        self.go_online_dialog(wizard)
617
618    def restore_wallet(self, wizard):
619        wizard.opt_bip39 = False
620        wizard.opt_slip39 = False
621        wizard.opt_ext = True
622        title = _("Restore two-factor Wallet")
623        f = lambda seed, seed_type, is_ext: wizard.run('on_restore_seed', seed, is_ext)
624        wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
625
626    def on_restore_seed(self, wizard, seed, is_ext):
627        f = lambda x: self.restore_choice(wizard, seed, x)
628        wizard.passphrase_dialog(run_next=f) if is_ext else f('')
629
630    def restore_choice(self, wizard: BaseWizard, seed, passphrase):
631        wizard.set_icon('trustedcoin-wizard.png')
632        wizard.reset_stack()
633        title = _('Restore 2FA wallet')
634        msg = ' '.join([
635            'You are going to restore a wallet protected with two-factor authentication.',
636            'Do you want to keep using two-factor authentication with this wallet,',
637            'or do you want to disable it, and have two master private keys in your wallet?'
638        ])
639        choices = [('keep', 'Keep'), ('disable', 'Disable')]
640        f = lambda x: self.on_choice(wizard, seed, passphrase, x)
641        wizard.choice_dialog(choices=choices, message=msg, title=title, run_next=f)
642
643    def on_choice(self, wizard, seed, passphrase, x):
644        if x == 'disable':
645            f = lambda pw, encrypt: wizard.run('on_restore_pw', seed, passphrase, pw, encrypt)
646            wizard.request_password(run_next=f)
647        else:
648            self.create_keystore(wizard, seed, passphrase)
649
650    def on_restore_pw(self, wizard, seed, passphrase, password, encrypt_storage):
651        xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
652        k1 = keystore.from_xprv(xprv1)
653        k2 = keystore.from_xprv(xprv2)
654        k1.add_seed(seed)
655        k1.update_password(None, password)
656        k2.update_password(None, password)
657        wizard.data['x1/'] = k1.dump()
658        wizard.data['x2/'] = k2.dump()
659        long_user_id, short_id = get_user_id(wizard.data)
660        xtype = xpub_type(xpub1)
661        xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id)
662        k3 = keystore.from_xpub(xpub3)
663        wizard.data['x3/'] = k3.dump()
664        wizard.pw_args = WizardWalletPasswordSetting(password=password,
665                                                     encrypt_storage=encrypt_storage,
666                                                     storage_enc_version=StorageEncryptionVersion.USER_PASSWORD,
667                                                     encrypt_keystore=bool(password))
668        wizard.terminate()
669
670    def create_remote_key(self, email, wizard):
671        xpub1 = wizard.data['x1/']['xpub']
672        xpub2 = wizard.data['x2/']['xpub']
673        # Generate third key deterministically.
674        long_user_id, short_id = get_user_id(wizard.data)
675        xtype = xpub_type(xpub1)
676        xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id)
677        # secret must be sent by the server
678        try:
679            r = server.create(xpub1, xpub2, email)
680        except (socket.error, ErrorConnectingServer):
681            wizard.show_message('Server not reachable, aborting')
682            wizard.terminate(aborted=True)
683            return
684        except TrustedCoinException as e:
685            if e.status_code == 409:
686                r = None
687            else:
688                wizard.show_message(str(e))
689                return
690        if r is None:
691            otp_secret = None
692        else:
693            otp_secret = r.get('otp_secret')
694            if not otp_secret:
695                wizard.show_message(_('Error'))
696                return
697            _xpub3 = r['xpubkey_cosigner']
698            _id = r['id']
699            if short_id != _id:
700                wizard.show_message("unexpected trustedcoin short_id: expected {}, received {}"
701                                    .format(short_id, _id))
702                return
703            if xpub3 != _xpub3:
704                wizard.show_message("unexpected trustedcoin xpub3: expected {}, received {}"
705                                    .format(xpub3, _xpub3))
706                return
707        self.request_otp_dialog(wizard, short_id, otp_secret, xpub3)
708
709    def check_otp(self, wizard, short_id, otp_secret, xpub3, otp, reset):
710        if otp:
711            self.do_auth(wizard, short_id, otp, xpub3)
712        elif reset:
713            wizard.opt_bip39 = False
714            wizard.opt_slip39 = False
715            wizard.opt_ext = True
716            f = lambda seed, seed_type, is_ext: wizard.run('on_reset_seed', short_id, seed, is_ext, xpub3)
717            wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
718
719    def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3):
720        f = lambda passphrase: wizard.run('on_reset_auth', short_id, seed, passphrase, xpub3)
721        wizard.passphrase_dialog(run_next=f) if is_ext else f('')
722
723    def do_auth(self, wizard, short_id, otp, xpub3):
724        try:
725            server.auth(short_id, otp)
726        except TrustedCoinException as e:
727            if e.status_code == 400:  # invalid OTP
728                wizard.show_message(_('Invalid one-time password.'))
729                # ask again for otp
730                self.request_otp_dialog(wizard, short_id, None, xpub3)
731            else:
732                wizard.show_message(str(e))
733                wizard.terminate(aborted=True)
734        except Exception as e:
735            wizard.show_message(repr(e))
736            wizard.terminate(aborted=True)
737        else:
738            k3 = keystore.from_xpub(xpub3)
739            wizard.data['x3/'] = k3.dump()
740            wizard.data['use_trustedcoin'] = True
741            wizard.terminate()
742
743    def on_reset_auth(self, wizard, short_id, seed, passphrase, xpub3):
744        xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
745        if (wizard.data['x1/']['xpub'] != xpub1 or
746                wizard.data['x2/']['xpub'] != xpub2):
747            wizard.show_message(_('Incorrect seed'))
748            return
749        r = server.get_challenge(short_id)
750        challenge = r.get('challenge')
751        message = 'TRUSTEDCOIN CHALLENGE: ' + challenge
752        def f(xprv):
753            rootnode = BIP32Node.from_xkey(xprv)
754            key = rootnode.subkey_at_private_derivation((0, 0)).eckey
755            sig = key.sign_message(message, True)
756            return base64.b64encode(sig).decode()
757
758        signatures = [f(x) for x in [xprv1, xprv2]]
759        r = server.reset_auth(short_id, challenge, signatures)
760        new_secret = r.get('otp_secret')
761        if not new_secret:
762            wizard.show_message(_('Request rejected by server'))
763            return
764        self.request_otp_dialog(wizard, short_id, new_secret, xpub3)
765
766    @hook
767    def get_action(self, db):
768        if db.get('wallet_type') != '2fa':
769            return
770        if not db.get('x1/'):
771            return self, 'show_disclaimer'
772        if not db.get('x2/'):
773            return self, 'show_disclaimer'
774        if not db.get('x3/'):
775            return self, 'accept_terms_of_use'
776