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