trustedcoin.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'