ttrustedcoin.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
ttrustedcoin.py (32783B)
---
1 #!/usr/bin/env python
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.
25 import asyncio
26 import socket
27 import json
28 import base64
29 import time
30 import hashlib
31 from collections import defaultdict
32 from typing import Dict, Union, Sequence, List
33
34 from urllib.parse import urljoin
35 from urllib.parse import quote
36 from aiohttp import ClientResponse
37
38 from electrum import ecc, constants, keystore, version, bip32, bitcoin
39 from electrum.bip32 import BIP32Node, xpub_type
40 from electrum.crypto import sha256
41 from electrum.transaction import PartialTxOutput, PartialTxInput, PartialTransaction, Transaction
42 from electrum.mnemonic import Mnemonic, seed_type, is_any_2fa_seed_type
43 from electrum.wallet import Multisig_Wallet, Deterministic_Wallet
44 from electrum.i18n import _
45 from electrum.plugin import BasePlugin, hook
46 from electrum.util import NotEnoughFunds, UserFacingException
47 from electrum.storage import StorageEncryptionVersion
48 from electrum.network import Network
49 from electrum.base_wizard import BaseWizard, WizardWalletPasswordSetting
50 from electrum.logging import Logger
51
52
53 def 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
65 def get_billing_xpub():
66 if constants.net.TESTNET:
67 return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r"
68 else:
69 return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU"
70
71
72 DISCLAIMER = [
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
90 KIVY_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 ]
101 RESTORE_MSG = _("Enter the seed for your 2-factor wallet:")
102
103 class TrustedCoinException(Exception):
104 def __init__(self, message, status_code=0):
105 Exception.__init__(self, message)
106 self.status_code = status_code
107
108
109 class 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
121 class 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
257 server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION)
258
259 class 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(self, *, coins: Sequence[PartialTxInput],
319 outputs: List[PartialTxOutput], fee=None,
320 change_addr: str = None, is_sweep=False) -> PartialTransaction:
321 mk_tx = lambda o: Multisig_Wallet.make_unsigned_transaction(
322 self, coins=coins, outputs=o, fee=fee, change_addr=change_addr)
323 extra_fee = self.extra_fee() if not is_sweep else 0
324 if extra_fee:
325 address = self.billing_info['billing_address_segwit']
326 fee_output = PartialTxOutput.from_address_and_value(address, extra_fee)
327 try:
328 tx = mk_tx(outputs + [fee_output])
329 except NotEnoughFunds:
330 # TrustedCoin won't charge if the total inputs is
331 # lower than their fee
332 tx = mk_tx(outputs)
333 if tx.input_value() >= extra_fee:
334 raise
335 self.logger.info("not charging for this tx")
336 else:
337 tx = mk_tx(outputs)
338 return tx
339
340 def on_otp(self, tx: PartialTransaction, otp):
341 if not otp:
342 self.logger.info("sign_transaction: no auth code")
343 return
344 otp = int(otp)
345 long_user_id, short_id = self.get_user_id()
346 raw_tx = tx.serialize_as_bytes().hex()
347 assert raw_tx[:10] == "70736274ff", f"bad magic. {raw_tx[:10]}"
348 try:
349 r = server.sign(short_id, raw_tx, otp)
350 except TrustedCoinException as e:
351 if e.status_code == 400: # invalid OTP
352 raise UserFacingException(_('Invalid one-time password.')) from e
353 else:
354 raise
355 if r:
356 received_raw_tx = r.get('transaction')
357 received_tx = Transaction(received_raw_tx)
358 tx.combine_with_other_psbt(received_tx)
359 self.logger.info(f"twofactor: is complete {tx.is_complete()}")
360 # reset billing_info
361 self.billing_info = None
362 self.plugin.start_request_thread(self)
363
364 def add_new_billing_address(self, billing_index: int, address: str, addr_type: str):
365 billing_addresses_of_this_type = self._billing_addresses[addr_type]
366 saved_addr = billing_addresses_of_this_type.get(billing_index)
367 if saved_addr is not None:
368 if saved_addr == address:
369 return # already saved this address
370 else:
371 raise Exception('trustedcoin billing address inconsistency.. '
372 'for index {}, already saved {}, now got {}'
373 .format(billing_index, saved_addr, address))
374 # do we have all prior indices? (are we synced?)
375 largest_index_we_have = max(billing_addresses_of_this_type) if billing_addresses_of_this_type else -1
376 if largest_index_we_have + 1 < billing_index: # need to sync
377 for i in range(largest_index_we_have + 1, billing_index):
378 addr = make_billing_address(self, i, addr_type=addr_type)
379 billing_addresses_of_this_type[i] = addr
380 self._billing_addresses_set.add(addr)
381 # save this address; and persist to disk
382 billing_addresses_of_this_type[billing_index] = address
383 self._billing_addresses_set.add(address)
384 self._billing_addresses[addr_type] = billing_addresses_of_this_type
385 self.db.put('trustedcoin_billing_addresses', self._billing_addresses['legacy'])
386 self.db.put('trustedcoin_billing_addresses_segwit', self._billing_addresses['segwit'])
387 # FIXME this often runs in a daemon thread, where storage.write will fail
388 self.db.write(self.storage)
389
390 def is_billing_address(self, addr: str) -> bool:
391 return addr in self._billing_addresses_set
392
393
394 # Utility functions
395
396 def get_user_id(db):
397 def make_long_id(xpub_hot, xpub_cold):
398 return sha256(''.join(sorted([xpub_hot, xpub_cold])))
399 xpub1 = db.get('x1/')['xpub']
400 xpub2 = db.get('x2/')['xpub']
401 long_id = make_long_id(xpub1, xpub2)
402 short_id = hashlib.sha256(long_id).hexdigest()
403 return long_id, short_id
404
405 def make_xpub(xpub, s) -> str:
406 rootnode = BIP32Node.from_xkey(xpub)
407 child_pubkey, child_chaincode = bip32._CKD_pub(parent_pubkey=rootnode.eckey.get_public_key_bytes(compressed=True),
408 parent_chaincode=rootnode.chaincode,
409 child_index=s)
410 child_node = BIP32Node(xtype=rootnode.xtype,
411 eckey=ecc.ECPubkey(child_pubkey),
412 chaincode=child_chaincode)
413 return child_node.to_xpub()
414
415 def make_billing_address(wallet, num, addr_type):
416 long_id, short_id = wallet.get_user_id()
417 xpub = make_xpub(get_billing_xpub(), long_id)
418 usernode = BIP32Node.from_xkey(xpub)
419 child_node = usernode.subkey_at_public_derivation([num])
420 pubkey = child_node.eckey.get_public_key_bytes(compressed=True)
421 if addr_type == 'legacy':
422 return bitcoin.public_key_to_p2pkh(pubkey)
423 elif addr_type == 'segwit':
424 return bitcoin.public_key_to_p2wpkh(pubkey)
425 else:
426 raise ValueError(f'unexpected billing type: {addr_type}')
427
428
429 class TrustedCoinPlugin(BasePlugin):
430 wallet_class = Wallet_2fa
431 disclaimer_msg = DISCLAIMER
432
433 def __init__(self, parent, config, name):
434 BasePlugin.__init__(self, parent, config, name)
435 self.wallet_class.plugin = self
436 self.requesting = False
437
438 @staticmethod
439 def is_valid_seed(seed):
440 t = seed_type(seed)
441 return is_any_2fa_seed_type(t)
442
443 def is_available(self):
444 return True
445
446 def is_enabled(self):
447 return True
448
449 def can_user_disable(self):
450 return False
451
452 @hook
453 def tc_sign_wrapper(self, wallet, tx, on_success, on_failure):
454 if not isinstance(wallet, self.wallet_class):
455 return
456 if tx.is_complete():
457 return
458 if wallet.can_sign_without_server():
459 return
460 if not wallet.keystores['x3/'].can_sign(tx, ignore_watching_only=True):
461 self.logger.info("twofactor: xpub3 not needed")
462 return
463 def wrapper(tx):
464 assert tx
465 self.prompt_user_for_otp(wallet, tx, on_success, on_failure)
466 return wrapper
467
468 def prompt_user_for_otp(self, wallet, tx, on_success, on_failure) -> None:
469 raise NotImplementedError()
470
471 @hook
472 def get_tx_extra_fee(self, wallet, tx: Transaction):
473 if type(wallet) != Wallet_2fa:
474 return
475 for o in tx.outputs():
476 if wallet.is_billing_address(o.address):
477 return o.address, o.value
478
479 def finish_requesting(func):
480 def f(self, *args, **kwargs):
481 try:
482 return func(self, *args, **kwargs)
483 finally:
484 self.requesting = False
485 return f
486
487 @finish_requesting
488 def request_billing_info(self, wallet: 'Wallet_2fa', *, suppress_connection_error=True):
489 if wallet.can_sign_without_server():
490 return
491 self.logger.info("request billing info")
492 try:
493 billing_info = server.get(wallet.get_user_id()[1])
494 except ErrorConnectingServer as e:
495 if suppress_connection_error:
496 self.logger.info(repr(e))
497 return
498 raise
499 billing_index = billing_info['billing_index']
500 # add segwit billing address; this will be used for actual billing
501 billing_address = make_billing_address(wallet, billing_index, addr_type='segwit')
502 if billing_address != billing_info['billing_address_segwit']:
503 raise Exception(f'unexpected trustedcoin billing address: '
504 f'calculated {billing_address}, received {billing_info["billing_address_segwit"]}')
505 wallet.add_new_billing_address(billing_index, billing_address, addr_type='segwit')
506 # also add legacy billing address; only used for detecting past payments in GUI
507 billing_address = make_billing_address(wallet, billing_index, addr_type='legacy')
508 wallet.add_new_billing_address(billing_index, billing_address, addr_type='legacy')
509
510 wallet.billing_info = billing_info
511 wallet.price_per_tx = dict(billing_info['price_per_tx'])
512 wallet.price_per_tx.pop(1, None)
513 return True
514
515 def start_request_thread(self, wallet):
516 from threading import Thread
517 if self.requesting is False:
518 self.requesting = True
519 t = Thread(target=self.request_billing_info, args=(wallet,))
520 t.setDaemon(True)
521 t.start()
522 return t
523
524 def make_seed(self, seed_type):
525 if not is_any_2fa_seed_type(seed_type):
526 raise Exception(f'unexpected seed type: {seed_type}')
527 return Mnemonic('english').make_seed(seed_type=seed_type)
528
529 @hook
530 def do_clear(self, window):
531 window.wallet.is_billing = False
532
533 def show_disclaimer(self, wizard: BaseWizard):
534 wizard.set_icon('trustedcoin-wizard.png')
535 wizard.reset_stack()
536 wizard.confirm_dialog(title='Disclaimer', message='\n\n'.join(self.disclaimer_msg), run_next = lambda x: wizard.run('choose_seed'))
537
538 def choose_seed(self, wizard):
539 title = _('Create or restore')
540 message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?')
541 choices = [
542 ('choose_seed_type', _('Create a new seed')),
543 ('restore_wallet', _('I already have a seed')),
544 ]
545 wizard.choice_dialog(title=title, message=message, choices=choices, run_next=wizard.run)
546
547 def choose_seed_type(self, wizard):
548 seed_type = '2fa' if self.config.get('nosegwit') else '2fa_segwit'
549 self.create_seed(wizard, seed_type)
550
551 def create_seed(self, wizard, seed_type):
552 seed = self.make_seed(seed_type)
553 f = lambda x: wizard.request_passphrase(seed, x)
554 wizard.show_seed_dialog(run_next=f, seed_text=seed)
555
556 @classmethod
557 def get_xkeys(self, seed, t, passphrase, derivation):
558 assert is_any_2fa_seed_type(t)
559 xtype = 'standard' if t == '2fa' else 'p2wsh'
560 bip32_seed = Mnemonic.mnemonic_to_seed(seed, passphrase)
561 rootnode = BIP32Node.from_rootseed(bip32_seed, xtype=xtype)
562 child_node = rootnode.subkey_at_private_derivation(derivation)
563 return child_node.to_xprv(), child_node.to_xpub()
564
565 @classmethod
566 def xkeys_from_seed(self, seed, passphrase):
567 t = seed_type(seed)
568 if not is_any_2fa_seed_type(t):
569 raise Exception(f'unexpected seed type: {t}')
570 words = seed.split()
571 n = len(words)
572 if t == '2fa':
573 if n >= 20: # old scheme
574 # note: pre-2.7 2fa seeds were typically 24-25 words, however they
575 # could probabilistically be arbitrarily shorter due to a bug. (see #3611)
576 # the probability of it being < 20 words is about 2^(-(256+12-19*11)) = 2^(-59)
577 if passphrase != '':
578 raise Exception('old 2fa seed cannot have passphrase')
579 xprv1, xpub1 = self.get_xkeys(' '.join(words[0:12]), t, '', "m/")
580 xprv2, xpub2 = self.get_xkeys(' '.join(words[12:]), t, '', "m/")
581 elif n == 12: # new scheme
582 xprv1, xpub1 = self.get_xkeys(seed, t, passphrase, "m/0'/")
583 xprv2, xpub2 = self.get_xkeys(seed, t, passphrase, "m/1'/")
584 else:
585 raise Exception(f'unrecognized seed length for "2fa" seed: {n}')
586 elif t == '2fa_segwit':
587 xprv1, xpub1 = self.get_xkeys(seed, t, passphrase, "m/0'/")
588 xprv2, xpub2 = self.get_xkeys(seed, t, passphrase, "m/1'/")
589 else:
590 raise Exception(f'unexpected seed type: {t}')
591 return xprv1, xpub1, xprv2, xpub2
592
593 def create_keystore(self, wizard, seed, passphrase):
594 # this overloads the wizard's method
595 xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
596 k1 = keystore.from_xprv(xprv1)
597 k2 = keystore.from_xpub(xpub2)
598 wizard.request_password(run_next=lambda pw, encrypt: self.on_password(wizard, pw, encrypt, k1, k2))
599
600 def on_password(self, wizard, password, encrypt_storage, k1, k2):
601 k1.update_password(None, password)
602 wizard.data['x1/'] = k1.dump()
603 wizard.data['x2/'] = k2.dump()
604 wizard.pw_args = WizardWalletPasswordSetting(password=password,
605 encrypt_storage=encrypt_storage,
606 storage_enc_version=StorageEncryptionVersion.USER_PASSWORD,
607 encrypt_keystore=bool(password))
608 self.go_online_dialog(wizard)
609
610 def restore_wallet(self, wizard):
611 wizard.opt_bip39 = False
612 wizard.opt_ext = True
613 title = _("Restore two-factor Wallet")
614 f = lambda seed, is_bip39, is_ext: wizard.run('on_restore_seed', seed, is_ext)
615 wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
616
617 def on_restore_seed(self, wizard, seed, is_ext):
618 f = lambda x: self.restore_choice(wizard, seed, x)
619 wizard.passphrase_dialog(run_next=f) if is_ext else f('')
620
621 def restore_choice(self, wizard: BaseWizard, seed, passphrase):
622 wizard.set_icon('trustedcoin-wizard.png')
623 wizard.reset_stack()
624 title = _('Restore 2FA wallet')
625 msg = ' '.join([
626 'You are going to restore a wallet protected with two-factor authentication.',
627 'Do you want to keep using two-factor authentication with this wallet,',
628 'or do you want to disable it, and have two master private keys in your wallet?'
629 ])
630 choices = [('keep', 'Keep'), ('disable', 'Disable')]
631 f = lambda x: self.on_choice(wizard, seed, passphrase, x)
632 wizard.choice_dialog(choices=choices, message=msg, title=title, run_next=f)
633
634 def on_choice(self, wizard, seed, passphrase, x):
635 if x == 'disable':
636 f = lambda pw, encrypt: wizard.run('on_restore_pw', seed, passphrase, pw, encrypt)
637 wizard.request_password(run_next=f)
638 else:
639 self.create_keystore(wizard, seed, passphrase)
640
641 def on_restore_pw(self, wizard, seed, passphrase, password, encrypt_storage):
642 xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
643 k1 = keystore.from_xprv(xprv1)
644 k2 = keystore.from_xprv(xprv2)
645 k1.add_seed(seed)
646 k1.update_password(None, password)
647 k2.update_password(None, password)
648 wizard.data['x1/'] = k1.dump()
649 wizard.data['x2/'] = k2.dump()
650 long_user_id, short_id = get_user_id(wizard.data)
651 xtype = xpub_type(xpub1)
652 xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id)
653 k3 = keystore.from_xpub(xpub3)
654 wizard.data['x3/'] = k3.dump()
655 wizard.pw_args = WizardWalletPasswordSetting(password=password,
656 encrypt_storage=encrypt_storage,
657 storage_enc_version=StorageEncryptionVersion.USER_PASSWORD,
658 encrypt_keystore=bool(password))
659 wizard.terminate()
660
661 def create_remote_key(self, email, wizard):
662 xpub1 = wizard.data['x1/']['xpub']
663 xpub2 = wizard.data['x2/']['xpub']
664 # Generate third key deterministically.
665 long_user_id, short_id = get_user_id(wizard.data)
666 xtype = xpub_type(xpub1)
667 xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id)
668 # secret must be sent by the server
669 try:
670 r = server.create(xpub1, xpub2, email)
671 except (socket.error, ErrorConnectingServer):
672 wizard.show_message('Server not reachable, aborting')
673 wizard.terminate(aborted=True)
674 return
675 except TrustedCoinException as e:
676 if e.status_code == 409:
677 r = None
678 else:
679 wizard.show_message(str(e))
680 return
681 if r is None:
682 otp_secret = None
683 else:
684 otp_secret = r.get('otp_secret')
685 if not otp_secret:
686 wizard.show_message(_('Error'))
687 return
688 _xpub3 = r['xpubkey_cosigner']
689 _id = r['id']
690 if short_id != _id:
691 wizard.show_message("unexpected trustedcoin short_id: expected {}, received {}"
692 .format(short_id, _id))
693 return
694 if xpub3 != _xpub3:
695 wizard.show_message("unexpected trustedcoin xpub3: expected {}, received {}"
696 .format(xpub3, _xpub3))
697 return
698 self.request_otp_dialog(wizard, short_id, otp_secret, xpub3)
699
700 def check_otp(self, wizard, short_id, otp_secret, xpub3, otp, reset):
701 if otp:
702 self.do_auth(wizard, short_id, otp, xpub3)
703 elif reset:
704 wizard.opt_bip39 = False
705 wizard.opt_ext = True
706 f = lambda seed, is_bip39, is_ext: wizard.run('on_reset_seed', short_id, seed, is_ext, xpub3)
707 wizard.restore_seed_dialog(run_next=f, test=self.is_valid_seed)
708
709 def on_reset_seed(self, wizard, short_id, seed, is_ext, xpub3):
710 f = lambda passphrase: wizard.run('on_reset_auth', short_id, seed, passphrase, xpub3)
711 wizard.passphrase_dialog(run_next=f) if is_ext else f('')
712
713 def do_auth(self, wizard, short_id, otp, xpub3):
714 try:
715 server.auth(short_id, otp)
716 except TrustedCoinException as e:
717 if e.status_code == 400: # invalid OTP
718 wizard.show_message(_('Invalid one-time password.'))
719 # ask again for otp
720 self.request_otp_dialog(wizard, short_id, None, xpub3)
721 else:
722 wizard.show_message(str(e))
723 wizard.terminate(aborted=True)
724 except Exception as e:
725 wizard.show_message(repr(e))
726 wizard.terminate(aborted=True)
727 else:
728 k3 = keystore.from_xpub(xpub3)
729 wizard.data['x3/'] = k3.dump()
730 wizard.data['use_trustedcoin'] = True
731 wizard.terminate()
732
733 def on_reset_auth(self, wizard, short_id, seed, passphrase, xpub3):
734 xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase)
735 if (wizard.data['x1/']['xpub'] != xpub1 or
736 wizard.data['x2/']['xpub'] != xpub2):
737 wizard.show_message(_('Incorrect seed'))
738 return
739 r = server.get_challenge(short_id)
740 challenge = r.get('challenge')
741 message = 'TRUSTEDCOIN CHALLENGE: ' + challenge
742 def f(xprv):
743 rootnode = BIP32Node.from_xkey(xprv)
744 key = rootnode.subkey_at_private_derivation((0, 0)).eckey
745 sig = key.sign_message(message, True)
746 return base64.b64encode(sig).decode()
747
748 signatures = [f(x) for x in [xprv1, xprv2]]
749 r = server.reset_auth(short_id, challenge, signatures)
750 new_secret = r.get('otp_secret')
751 if not new_secret:
752 wizard.show_message(_('Request rejected by server'))
753 return
754 self.request_otp_dialog(wizard, short_id, new_secret, xpub3)
755
756 @hook
757 def get_action(self, db):
758 if db.get('wallet_type') != '2fa':
759 return
760 if not db.get('x1/'):
761 return self, 'show_disclaimer'
762 if not db.get('x2/'):
763 return self, 'show_disclaimer'
764 if not db.get('x3/'):
765 return self, 'accept_terms_of_use'