From a82a6806306e5b0b623cdee39370688967025809 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Mon, 11 Oct 2021 21:06:22 -0400 Subject: [PATCH] add bitcoin address verification --- src/seedsigner/controller.py | 203 ++++++++++++++++++++++++++++- src/seedsigner/models/decode_qr.py | 132 +++++++++++++++++-- src/seedsigner/models/qr_type.py | 3 + tests/test_decodepsbtqr.py | 38 +++++- 4 files changed, 363 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 085bd2ef..eaaf46ce 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,10 +1,12 @@ # External Dependencies import time +import re from multiprocessing import Process, Queue from subprocess import call import os, sys -from embit import bip32 +from embit import bip32, script, ec from embit.networks import NETWORKS +from embit.descriptor import Descriptor from binascii import hexlify from threading import Thread @@ -516,7 +518,6 @@ class Controller(Singleton): fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') xprv = root.derive(derivation) xpub = xprv.to_public() - print(derivation) xpub_base58 = xpub.to_string(version=version) self.signing_tools_view.display_xpub_info(fingerprint, derivation, xpub_base58) @@ -654,6 +655,204 @@ class Controller(Singleton): else: return Path.MAIN_MENU + elif decoder.isComplete() and decoder.isAddress(): + + address = decoder.getAddress() + address_type = decoder.getAddressType() + # address_parts = textwrap.wrap(address, 30) + address_parts = [address[i:i+22] for i in range(0, len(address), 22)] + address_part_1 = address_parts[0] + address_part_2 = address_parts[1] if len(address_parts) >= 2 else "" + address_part_3 = address_parts[2] if len(address_parts) >= 3 else "" + self.menu_view.draw_modal(["Bitcoin Address", address_part_1, address_part_2, address_part_3], "", "Right to Continue") + self.buttons.wait_for([B.KEY_RIGHT]) + + validate_network = NETWORKS[self.settings.network] + if "main" in address_type: + validate_network = NETWORKS["main"] + elif "test" in address_type: + validate_network = NETWORKS["test"] + + r = 0 + if address_type in ("Bech32-main", "Bech32-test") and len(address) == 62: + r = 2 + else: + # check single sig vs multi sig address + r = self.menu_view.display_generic_selection_menu(["Single Sig Seed", "JSON Wallet Desc"], "Validate Bitcoin Address") + + if r == 1: + + # validate single sig using seed + + # No valid seed yet, If there is a saved seed, ask to use saved seed + if self.storage.num_of_saved_seeds() > 0: + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Use Save Seed?") + if r == 1: #Yes + slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None) + if slot_num == 0: + return Path.MAIN_MENU + seed = self.storage.get_seed(slot_num) + used_saved_seed = True + + if not seed: + # no valid seed yet, gather seed phrase + # display menu to select 12 or 24 word seed for last word + ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Main Menu ]") + if ret_val == Path.SEED_WORD_12: + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(12) + elif ret_val == Path.SEED_WORD_24: + seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(24) + elif ret_val == Path.SEED_WORD_QR: + seed.mnemonic = self.seed_tools_view.read_seed_phrase_qr() + else: + return Path.MAIN_MENU + + if not seed: + return Path.MAIN_MENU + + # check if seed phrase is valid + self.menu_view.draw_modal(["Validating Seed ..."]) + if not seed: + self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue") + input = self.buttons.wait_for([B.KEY_RIGHT]) + return Path.MAIN_MENU + + if len(seed.passphrase) == 0: + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?") + if r == 1: + # display a tool to pick letters/numbers to make a passphrase + seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() + if len(seed.passphrase) == 0: + self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") + input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT]) + if input == B.KEY_LEFT: + return Path.MAIN_MENU + else: + self.menu_view.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue") + self.buttons.wait_for([B.KEY_RIGHT]) + + # display seed phrase + while True: + r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, seed.passphrase, "Right to Continue") + if r == True: + break + else: + # Cancel + return Path.MAIN_MENU + + # Ask to save seed + if self.storage.slot_avaliable() and used_saved_seed == False: + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?") + if r == 1: #Yes + slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) + if slot_num in (1,2,3): + self.storage.add_seed(seed, slot_num) + self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue") + input = self.buttons.wait_for([B.KEY_RIGHT]) + + # + # Validate if address from seed + # + + # choose derivation standard + r = self.menu_view.display_generic_selection_menu(["Native Segwit", "Nested Segwit", "Custom"], "Derivation Path?") + if r == 1: + script_type = "native segwit" + elif r == 2: + script_type = "nested segwit" + elif r == 3: + script_type = "custom" + + # calculated derivation or get custom from keyboard entry + if script_type == "custom": + derivation = self.settings_tools_view.draw_derivation_keyboard_entry(existing_derivation=self.settings.custom_derivation) + self.settings.custom_derivation = derivation # save for next time + else: + derivation = Settings.calc_derivation(validate_network, "single sig", script_type) + + if derivation == "" or derivation == None: + self.menu_view.draw_modal(["Invalid Derivation", "try again"], "", "Right to Continue") + return Path.SEED_TOOLS_SUB_MENU + + self.menu_view.draw_modal(["Checking Address", ""], "", "") + + version = bip32.detect_version(derivation, default="xpub", network=validate_network) + root = bip32.HDKey.from_seed(seed.seed, version=validate_network["xprv"]) + fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8') + xprv = root.derive(derivation) + xpub = xprv.to_public() + + for i in range(500): + r_pubkey = xpub.derive([0,i]).key + c_pubkey = xpub.derive([1,i]).key + + recieve_address = "" + change_address = "" + + if "P2PKH" in address_type: + recieve_address = script.p2pkh(r_pubkey).address(network=validate_network) + change_address = script.p2pkh(c_pubkey).address(network=validate_network) + elif "Bech32" in address_type: + recieve_address = script.p2wpkh(r_pubkey).address(network=validate_network) + change_address = script.p2wpkh(c_pubkey).address(network=validate_network) + elif "P2SH" in address_type: + recieve_address = script.p2sh(script.p2wpkh(r_pubkey)).address(network=validate_network) + change_address = script.p2sh(script.p2wpkh(c_pubkey)).address(network=validate_network) + + if address == recieve_address: + self.menu_view.draw_modal(["Recieved Address "+str(i), "Verified"], "", "Right to Exit") + input = self.buttons.wait_for([B.KEY_RIGHT]) + return Path.MAIN_MENU + if address == change_address: + self.menu_view.draw_modal(["Change Address "+str(i), "Verified"], "", "Right to Exit") + input = self.buttons.wait_for([B.KEY_RIGHT]) + return Path.MAIN_MENU + else: + self.menu_view.draw_modal(["Checking Address "+str(i), "..."], "", "Right to Abort") + if self.buttons.check_for_low(B.KEY_RIGHT) or self.buttons.check_for_low(B.KEY_LEFT): + return Path.MAIN_MENU + + if r == 2: + + # second QR scan need PSBT now + decoder2 = scan_qr("Scan Backup JSON QR") + + if decoder2.isComplete() and decoder2.isWalletDescriptor(): + desc_str = decoder2.getWalletDescriptor() + try: + if len(re.findall(r'\[([0-9,a-f,A-F]+?)(\/[0-9,\/,h\']+?)\].*?(\/0\/\*)', desc_str)) > 0: + p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h\']+?\].*?)(\/0\/\*)') + desc_str = p.sub(r'\1/{0,1}/*', desc_str) + except: + desc_str = decoder2.getWalletDescriptor() + desc = Descriptor.from_string(desc_str) + else: + return Path.MAIN_MENU + + # + # Validate if address from descriptor + # + + for i in range(1000): + r_derived = desc.derive(i,branch_index=0) + c_derived = desc.derive(i,branch_index=1) + + recieve_address = r_derived.address(validate_network) + change_address = c_derived.address(validate_network) + + if address == recieve_address: + self.menu_view.draw_modal(["Recieved Address "+str(i), "Verified"], "", "Right to Exit") + input = self.buttons.wait_for([B.KEY_RIGHT]) + return Path.MAIN_MENU + if address == change_address: + self.menu_view.draw_modal(["Change Address "+str(i), "Verified"], "", "Right to Exit") + input = self.buttons.wait_for([B.KEY_RIGHT]) + return Path.MAIN_MENU + else: + self.menu_view.draw_modal(["Checking Address "+str(i), "..."], "", "Right to Abort") + if self.buttons.check_for_low(B.KEY_RIGHT) or self.buttons.check_for_low(B.KEY_LEFT): + return Path.MAIN_MENU + elif ( decoder.isComplete() and not decoder.isPSBT() ) or decoder.isInvalid(): self.menu_view.draw_modal(["Not a valid PSBT QR"], "", "Right to Exit") input = self.buttons.wait_for([B.KEY_RIGHT]) diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 24e194d4..9d07ed78 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -2,6 +2,7 @@ from pyzbar import pyzbar from pyzbar.pyzbar import ZBarSymbol from enum import IntEnum import re +import json import base64 from embit import psbt from binascii import a2b_base64, b2a_base64 @@ -25,6 +26,7 @@ class DecodeQR: self.base64_qr = Base64DecodeQR() # Single Segments Base64 self.base43_qr = Base43DecodeQR() # Single Segment Base43 self.address_qr = BitcoinAddressQR() # Single Segment bitcoin address + self.specter_wallet_qr = SpecterDecodeWalletQR() # Specter Desktop Wallet Export decoder self.wordlist = None for key, value in kwargs.items(): @@ -115,6 +117,12 @@ class DecodeQR: if rt == DecodeQRStatus.COMPLETE: self.complete = True return rt + + elif self.qr_type == QRType.SPECTERWALLETQR: + rt = self.specter_wallet_qr.add(qr_str) + if rt == DecodeQRStatus.COMPLETE: + self.complete = True + return rt else: return DecodeQRStatus.INVALID @@ -163,6 +171,9 @@ class DecodeQR: def getAddressType(self): return self.address_qr.getAddressType() + + def getWalletDescriptor(self): + return self.specter_wallet_qr.getWalletDescriptor() def getPercentComplete(self) -> int: if self.qr_type == QRType.PSBTUR2: @@ -207,7 +218,12 @@ class DecodeQR: return False def isAddress(self): - if self.qr_type in (QRType.BITCOINADDRESSQR): + if self.qr_type == QRType.BITCOINADDRESSQR: + return True + return False + + def isWalletDescriptor(self): + if self.qr_type in (QRType.SPECTERWALLETQR, QRType.URWALLETQR, QRType.BLUEWALLETQR): return True return False @@ -231,22 +247,36 @@ class DecodeQR: # PSBT if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBTUR2 - elif re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE): + elif re.search(r'^p(\d+)of(\d+) ([A-Za-z0-9+\/=]+$)', s, re.IGNORECASE): #must be base64 characters only in segment return QRType.PSBTSPECTER elif re.search("^UR:BYTES/", s, re.IGNORECASE): return QRType.PSBTURLEGACY elif DecodeQR.isBase64PSBT(s): return QRType.PSBTBASE64 - - # Bitcoin Address - if DecodeQR.isBitcoinAddress(s): - return QRType.BITCOINADDRESSQR + + # Wallet Descriptor + elif re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE): + # when not a SPECTER Base64 PSBT from above, assume it's json + return QRType.SPECTERWALLETQR + elif re.search(r'^\{\"label\".*\"descriptor\"\:.*', s, re.IGNORECASE): + # if json starting with label and contains descriptor, assume specter wallet json + return QRType.SPECTERWALLETQR - _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] + # create 4 letter wordlist only if not PSBT (performance gain) + try: + _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] + except: + _4LETTER_WORDLIST = [] # Seed if re.search(r'\d{48,96}', s): return QRType.SEEDSSQR + + # Bitcoin Address + elif DecodeQR.isBitcoinAddress(s): + return QRType.BITCOINADDRESSQR + + # Seed elif all(x in wordlist for x in s.strip().split(" ")): # checks if all words in list are in bip39 word list return QRType.SEEDMNEMONIC @@ -255,8 +285,8 @@ class DecodeQR: return QRType.SEED4LETTERMNEMONIC elif DecodeQR.isBase43PSBT(s): return QRType.PSBTBASE43 - else: - return QRType.INVALID + + return QRType.INVALID @staticmethod def isBase64(s): @@ -709,6 +739,90 @@ class BitcoinAddressQR: return "Unknown" return None +### +### SpecterDecodeWalletQR Class +### Purpose: used in DecodeQR to decode animated frames to get a wallet descriptor from Specter Desktop +### + +class SpecterDecodeWalletQR: + + def __init__(self): + self.total_segments = None + self.collected_segments = 0 + self.complete = False + self.segments = [] + + def add(self, segment): + if self.total_segments == None: + self.total_segments = SpecterDecodeWalletQR.totalSegmentNum(segment) + self.segments = [None] * self.total_segments + elif self.total_segments != SpecterDecodeWalletQR.totalSegmentNum(segment): + raise Exception('Specter Desktop segment total changed unexpectedly') + + if self.segments[SpecterDecodeWalletQR.currentSegmentNum(segment) - 1] == None: + self.segments[SpecterDecodeWalletQR.currentSegmentNum(segment) - 1] = SpecterDecodeWalletQR.parseSegment(segment) + self.collected_segments += 1 + if self.total_segments == self.collected_segments: + if self.validateWalletDescriptor(): + self.complete = True + return DecodeQRStatus.COMPLETE + else: + return DecodeQRStatus.INVALID + return DecodeQRStatus.PART_COMPLETE # new segment added + + return DecodeQRStatus.PART_EXISTING # segment not added because it's already been added + + def validateJson(self) -> str: + try: + j = "".join(self.segments) + json.loads(j) + except json.decoder.JSONDecodeError: + return False + return True + + def validateWalletDescriptor(self): + if self.validateJson(): + j = "".join(self.segments) + data = json.loads(j) + if "descriptor" in data: + return True + return False + + def getWalletDescriptor(self) -> str: + if self.validateWalletDescriptor(): + j = "".join(self.segments) + data = json.loads(j) + return data['descriptor'] + return None + + def is_complete(self) -> bool: + if self.complete and self.validateWalletDescriptor(): + return True + return False + + @staticmethod + def currentSegmentNum(segment) -> int: + if DecodeQR.SegmentType(segment) == QRType.SPECTERWALLETQR: + if re.search(r'^p(\d+)of(\d+) ', segment, re.IGNORECASE) != None: + return int(re.search(r'^p(\d+)of(\d+) ', segment, re.IGNORECASE).group(1)) + else: + return 1 + + @staticmethod + def totalSegmentNum(segment) -> int: + if DecodeQR.SegmentType(segment) == QRType.SPECTERWALLETQR: + if re.search(r'^p(\d+)of(\d+) ', segment, re.IGNORECASE) != None: + return int(re.search(r'^p(\d+)of(\d+) ', segment, re.IGNORECASE).group(2)) + else: + return 1 + + @staticmethod + def parseSegment(segment) -> str: + try: + return re.search(r'^p(\d+)of(\d+) (.+$)', segment, re.IGNORECASE).group(3) + except: + return segment + ### ### DecodeQRStatus Class IntEum ### Purpose: used in DecodeQR to communicate status of adding qr frame/segment diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 0bb7919e..984e5d4c 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -18,4 +18,7 @@ class QRType(IntEnum): XPUBQR = 20 SPECTERXPUBQR = 21 BITCOINADDRESSQR = 30 + SPECTERWALLETQR = 40 + URWALLETQR = 41 + BLUEWALLETQR = 42 INVALID = 100 \ No newline at end of file diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index 98ba3b1b..270ae0f7 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -404,6 +404,40 @@ def test_bitcoin_address(): assert d.getAddress() == "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" assert d.getAddressType() == "Bech32-main" - - +def test_seed_qr(): + seed = "121802020768124106400009195602431595117715840445" + + d = DecodeQR(wordlist=bip39.WORDLIST) + d.addString(seed) + + assert d.qrType() == QRType.SEEDSSQR + assert d.getSeedPhrase() == "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split() + +def test_specter_wallet_json(): + + parts = [ + 'p1of3 {"label": "SeedSigner Dev Funds", "blockheight": 692143, "descriptor": "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7Wpofr' + , 'p2of3 bYeNo3Wv2AiLUMeyrPwotXfukFxUHbK4JxaLHTd5394QtH5wnjFzBgr2YnJpHhXv25Zsqv2APmMFvH1DsKHj5LCr3pmXs/0/*,[b433e095/48h/0h/0h/2h]xpub6EF51itHko2YhGTjVeuYbBgJjVbTzzpYzn2a3JwZHpDrMePRVgXGBHMx2Yv1KwgLsUn9i7ExcAo8uqMx4pDjVRY9J7qnceFAwRRj16dd5AS/0/*,[184d07eb/48h/0h/0h/2h]xpub6EEoTpcQu7N4R8D84pJjZ69j3mi' + , 'p3of3 nevnYLDDoo2HBzYBXTQ4rGVf4XGTyCYFwJuZdsF9MyFYJNzYEjg5LGMA1ubTGWuDnjHAZz6ficVRDTSy/0/*,[3e451efe/48h/0h/0h/2h]xpub6ExQPvQxGBMaPxr8Fv7Vq91ztJFFX3VWvtpvex6UPZ1AptTeuAiJGCtKkgwJkrwpMZMagh9ex6rL4sM8axfFcdQbERoFCRUKTJxrBkJh56g/0/*))#c44hel9e", "devices": [{"type": "other", "label": "Keith"}, {"type": "other", "label": "Nick"}, {"type": "other", "label": "Richard"}, {"type": "other", "label": "Stephan"}, {"type": "other", "label": "SeedSigner 1"}, {"type": "other", "label": "SeedSigner 2"}]}' + ] + + d = DecodeQR(wordlist=bip39.WORDLIST) + d.addString(parts[1]) + d.addString(parts[2]) + d.addString(parts[0]) + + assert d.qrType() == QRType.SPECTERWALLETQR + assert d.isComplete() == True + assert d.getWalletDescriptor() == "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7WpofrbYeNo3Wv2AiLUMeyrPwotXfukFxUHbK4JxaLHTd5394QtH5wnjFzBgr2YnJpHhXv25Zsqv2APmMFvH1DsKHj5LCr3pmXs/0/*,[b433e095/48h/0h/0h/2h]xpub6EF51itHko2YhGTjVeuYbBgJjVbTzzpYzn2a3JwZHpDrMePRVgXGBHMx2Yv1KwgLsUn9i7ExcAo8uqMx4pDjVRY9J7qnceFAwRRj16dd5AS/0/*,[184d07eb/48h/0h/0h/2h]xpub6EEoTpcQu7N4R8D84pJjZ69j3minevnYLDDoo2HBzYBXTQ4rGVf4XGTyCYFwJuZdsF9MyFYJNzYEjg5LGMA1ubTGWuDnjHAZz6ficVRDTSy/0/*,[3e451efe/48h/0h/0h/2h]xpub6ExQPvQxGBMaPxr8Fv7Vq91ztJFFX3VWvtpvex6UPZ1AptTeuAiJGCtKkgwJkrwpMZMagh9ex6rL4sM8axfFcdQbERoFCRUKTJxrBkJh56g/0/*))#c44hel9e" + +def test_specter_wallet_json2(): + + 1_part = '{"label": "Testnet Single Zone", "blockheight": 2090512, "descriptor": "wpkh([990a73ad/84h/1h/0h]tpubDDHQMDnFdan2GyHBsG32VW9qiygbhVizGRTjiS3H79M49FSvpsvLXqLgp1yC7r43dXVHozWavi2Fc4WHUpZmQYmzoQbit28qJhLjScbAQWU/0/*)#ujr0xunp","devices": [{"type": "seedsigner", "label": "Single Seed Zone Testnet"}]}' + + d = DecodeQR(wordlist=bip39.WORDLIST) + d.addString(1_part) + + assert d.qrType() == QRType.SPECTERWALLETQR + assert d.isComplete() == True +