diff --git a/src/seedsigner/helpers/bcur.py b/src/seedsigner/helpers/bcur.py deleted file mode 100644 index b131b70a..00000000 --- a/src/seedsigner/helpers/bcur.py +++ /dev/null @@ -1,181 +0,0 @@ -### bc-ur encoding stuff -from io import BytesIO -import hashlib - -CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - - -def bech32_polymod(values): - """Internal function that computes the Bech32 checksum.""" - generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] - chk = 1 - for value in values: - top = chk >> 25 - chk = (chk & 0x1FFFFFF) << 5 ^ value - for i in range(5): - chk ^= generator[i] if ((top >> i) & 1) else 0 - return chk - - -def bech32_hrp_expand(hrp): - """Expand the HRP into values for checksum computation.""" - return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] - - -def bech32_verify_checksum(hrp, data): - """Verify a checksum given HRP and converted data characters.""" - return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 - - -def bech32_create_checksum(hrp, data): - """Compute the checksum values given HRP and data.""" - values = bech32_hrp_expand(hrp) + data - polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 - return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] - - -def bech32_encode(hrp, data): - """Compute a Bech32 string given HRP and data values.""" - combined = data + bech32_create_checksum(hrp, data) - return hrp + "1" + "".join([CHARSET[d] for d in combined]) - - -def bech32_decode(bech): - """Validate a Bech32 string, and determine HRP and data.""" - if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( - bech.lower() != bech and bech.upper() != bech - ): - return (None, None) - bech = bech.lower() - pos = bech.rfind("1") - if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: - return (None, None) - if not all(x in CHARSET for x in bech[pos + 1 :]): - return (None, None) - hrp = bech[:pos] - data = [CHARSET.find(x) for x in bech[pos + 1 :]] - if not bech32_verify_checksum(hrp, data): - return (None, None) - return (hrp, data[:-6]) - - -def convertbits(data, frombits, tobits, pad=True): - """General power-of-2 base conversion.""" - acc = 0 - bits = 0 - ret = [] - maxv = (1 << tobits) - 1 - max_acc = (1 << (frombits + tobits - 1)) - 1 - for value in data: - if value < 0 or (value >> frombits): - return None - acc = ((acc << frombits) | value) & max_acc - bits += frombits - while bits >= tobits: - bits -= tobits - ret.append((acc >> bits) & maxv) - if pad: - if bits: - ret.append((acc << (tobits - bits)) & maxv) - elif bits >= frombits or ((acc << (tobits - bits)) & maxv): - return None - return ret - - -def decode(hrp, addr): - """Decode a segwit address.""" - hrpgot, data = bech32_decode(addr) - if hrpgot != hrp: - return (None, None) - decoded = convertbits(data[1:], 5, 8, False) - if decoded is None or len(decoded) < 2 or len(decoded) > 40: - return (None, None) - if data[0] > 16: - return (None, None) - if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: - return (None, None) - return (data[0], decoded) - - -def encode(hrp, witver, witprog): - """Encode a segwit address.""" - ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) - if decode(hrp, ret) == (None, None): - return None - return ret - - -def bc32encode(data: bytes) -> str: - """ - bc32 encoding - see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md - """ - dd = convertbits(data, 8, 5) - polymod = bech32_polymod([0] + dd + [0, 0, 0, 0, 0, 0]) ^ 0x3FFFFFFF - chk = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] - return "".join([CHARSET[d] for d in dd + chk]) - - -def bc32decode(bc32: str) -> bytes: - """ - bc32 decoding - see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md - """ - if bc32.lower() != bc32 and bc32.upper() != bc32: - return None - bc32 = bc32.lower() - if not all([x in CHARSET for x in bc32]): - return None - res = [CHARSET.find(c) for c in bc32.lower()] - if bech32_polymod([0] + res) != 0x3FFFFFFF: - return None - return bytes(convertbits(res[:-6], 5, 8, False)) - - -def cbor_encode(data): - l = len(data) - if l <= 23: - prefix = bytes([0x40 + l]) - elif l <= 255: - prefix = bytes([0x58, l]) - elif l <= 65535: - prefix = b"\x59" + l.to_bytes(2, "big") - else: - prefix = b"\x60" + l.to_bytes(4, "big") - return prefix + data - - -def cbor_decode(data): - s = BytesIO(data) - b = s.read(1)[0] - if b >= 0x40 and b < 0x58: - l = b - 0x40 - return s.read(l) - if b == 0x58: - l = s.read(1)[0] - return s.read(l) - if b == 0x59: - l = int.from_bytes(s.read(2), "big") - return s.read(l) - if b == 0x60: - l = int.from_bytes(s.read(4), "big") - return s.read(l) - return None - - -def bcur_encode(data): - """Returns bcur encoded string and hash digest""" - cbor = cbor_encode(data) - enc = bc32encode(cbor) - h = hashlib.sha256(cbor).digest() - enc_hash = bc32encode(h) - return enc, enc_hash - - -def bcur_decode(data, checksum=None): - """Returns decoded data, verifies hash digest if provided""" - cbor = bc32decode(data) - if checksum is not None: - h = bc32decode(checksum) - assert h == hashlib.sha256(cbor).digest() - return cbor_decode(cbor) \ No newline at end of file diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 8b299209..0118e8fe 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -9,9 +9,10 @@ from embit import psbt, bip39 from pyzbar import pyzbar from pyzbar.pyzbar import ZBarSymbol from urtypes.crypto import PSBT as UR_PSBT +from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP +from urtypes.bytes import Bytes from seedsigner.helpers.ur2.ur_decoder import URDecoder -from seedsigner.helpers.bcur import (cbor_decode, bc32decode) from seedsigner.models.psbt_parser import PSBTParser from . import QRType, Seed @@ -64,15 +65,12 @@ class DecodeQR: if self.qr_type == None: self.qr_type = qr_type - if self.qr_type == QRType.PSBT__UR2: - self.decoder = URDecoder() # UR2 decoder + if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: + self.decoder = URDecoder() # BCUR Decoder elif self.qr_type == QRType.PSBT__SPECTER: self.decoder = SpecterPsbtQrDecoder() # Specter Desktop PSBT QR base64 decoder - elif self.qr_type == QRType.PSBT__LEGACY_UR: - self.decoder = LegacyUrPsbtQrDecoder() # UR Legacy decoder - elif self.qr_type == QRType.PSBT__BASE64: self.decoder = Base64PsbtQrDecoder() # Single Segments Base64 @@ -93,6 +91,9 @@ class DecodeQR: elif self.qr_type == QRType.WALLET__GENERIC: self.decoder = GenericWalletQrDecoder() + + elif self.qr_type == QRType.WALLET__CONFIGFILE: + self.decoder = MultiSigConfigFileQRDecoder() elif self.qr_type != qr_type: raise Exception('QR Fragment Unexpected Type Change') @@ -114,7 +115,7 @@ class DecodeQR: # it's already str data qr_str = data - if self.qr_type == QRType.PSBT__UR2: + if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: self.decoder.receive_part(qr_str) if self.decoder.is_complete(): self.complete = True @@ -192,17 +193,29 @@ class DecodeQR: def get_wallet_descriptor(self): if self.is_wallet_descriptor: - return self.decoder.get_wallet_descriptor() + if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: + cbor = self.decoder.result_message().cbor + if self.qr_type == QRType.OUTPUT__UR: + return Output.from_cbor(cbor).descriptor() + elif self.qr_type == QRType.ACCOUNT__UR: + return Account.from_cbor(cbor).output_descriptors[0].descriptor() + elif self.qr_type == QRType.BYTES__UR: + raw = Bytes.from_cbor(cbor).data + descriptor = DecodeQR.multisig_setup_file_to_descriptor(raw.decode("utf-8")) + return descriptor + else: + # All the other wallet output descriptor decoder types use the same method signature + return self.decoder.get_wallet_descriptor() def get_percent_complete(self) -> int: if not self.decoder: return 0 - if self.qr_type == QRType.PSBT__UR2: + if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR]: return int(self.decoder.estimated_percent_complete() * 100) - elif self.qr_type in [QRType.PSBT__SPECTER, QRType.PSBT__LEGACY_UR]: + elif self.qr_type in [QRType.PSBT__SPECTER]: if self.decoder.total_segments == None: return 0 return int((self.decoder.collected_segments / self.decoder.total_segments) * 100) @@ -233,12 +246,10 @@ class DecodeQR: return self.qr_type in [ QRType.PSBT__UR2, QRType.PSBT__SPECTER, - QRType.PSBT__LEGACY_UR, QRType.PSBT__BASE64, QRType.PSBT__BASE43, ] - @property def is_seed(self): return self.qr_type in [ @@ -262,8 +273,14 @@ class DecodeQR: @property def is_wallet_descriptor(self): - return self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__BLUEWALLET, QRType.WALLET__GENERIC] - + check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR] + + if self.qr_type in [QRType.BYTES__UR]: + cbor = self.decoder.result_message().cbor + raw = Bytes.from_cbor(cbor).data + check = 'multisig setup file' in raw.decode("utf-8").lower() + + return check @property def is_settings(self): @@ -303,12 +320,18 @@ class DecodeQR: # PSBT if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBT__UR2 + + elif re.search("^UR:CRYPTO-OUTPUT/", s, re.IGNORECASE): + return QRType.OUTPUT__UR + + elif re.search("^UR:CRYPTO-ACCOUNT/", s, re.IGNORECASE): + return QRType.ACCOUNT__UR elif re.search(r'^p(\d+)of(\d+) ([A-Za-z0-9+\/=]+$)', s, re.IGNORECASE): #must be base64 characters only in segment return QRType.PSBT__SPECTER elif re.search("^UR:BYTES/", s, re.IGNORECASE): - return QRType.PSBT__LEGACY_UR + return QRType.BYTES__UR elif DecodeQR.is_base64_psbt(s): return QRType.PSBT__BASE64 @@ -323,6 +346,9 @@ class DecodeQR: # if json starting with label and contains descriptor, assume specter wallet json return QRType.WALLET__SPECTER + elif "multisig setup file" in s.lower(): + return QRType.WALLET__CONFIGFILE + elif "sortedmulti" in s: return QRType.WALLET__GENERIC @@ -453,8 +479,101 @@ class DecodeQR: return True else: return False - - + + @staticmethod + def multisig_setup_file_to_descriptor(text) -> str: + # sample text file, parse the contents and create descriptor + """ + Name: SeedSigner Dev Funds + Policy: 4 of 6 + Derivation: m/48'/0'/0'/2' + Format: P2WSH + + E0811B6B: xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt + 852B308F: xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh + 7EDF9C59: xpub6DaFfKoe7WpofrbYeNo3Wv2AiLUMeyrPwotXfukFxUHbK4JxaLHTd5394QtH5wnjFzBgr2YnJpHhXv25Zsqv2APmMFvH1DsKHj5LCr3pmXs + B433E095: xpub6EF51itHko2YhGTjVeuYbBgJjVbTzzpYzn2a3JwZHpDrMePRVgXGBHMx2Yv1KwgLsUn9i7ExcAo8uqMx4pDjVRY9J7qnceFAwRRj16dd5AS + 184D07EB: xpub6EEoTpcQu7N4R8D84pJjZ69j3minevnYLDDoo2HBzYBXTQ4rGVf4XGTyCYFwJuZdsF9MyFYJNzYEjg5LGMA1ubTGWuDnjHAZz6ficVRDTSy + 3E451EFE: xpub6ExQPvQxGBMaPxr8Fv7Vq91ztJFFX3VWvtpvex6UPZ1AptTeuAiJGCtKkgwJkrwpMZMagh9ex6rL4sM8axfFcdQbERoFCRUKTJxrBkJh56g + """ + + lines = text.split('\n') + + m = 0 + n = 0 + xpubs = [] + x = 0 + derivation = '' + descriptor = '' + + lines = text.split('\n') + + for l in lines: + if l.find('#') == 0: + # skip comments + continue + + l = l.strip() + + if ':' not in l: + # when label/value divider not found, skip line + continue + + label, value = l.split(':', 1) + label = label.strip().lower() + value = value.strip() + + if label == 'policy': + try: + match = re.search(r'(\d+)\D*(\d+)', value) + m = int(match.group(1)) + n = int(match.group(2)) + except: + raise Exception(f"Policy line not supported") + elif label == 'derivation': + derivation = value + elif label == 'format': + if value.lower() in ['p2wsh', 'p2sh-p2wsh', 'p2wsh-p2sh']: + script_type = value.lower() + elif len(label) == 8: + if len(xpubs) == 0: + xpubs = [None] * n + + xpubs[x] = {'xfp': label, 'key': value} + x += 1 + + if None in xpubs or len(xpubs) != n: + raise Exception(f"bad or missing xpub") + + if m <= 0 or m > 9 or n <= 0 or n > 9: + raise Exception(f"bad or missing policy") + + if len(derivation) == 0: + raise Exception(f"bad or missing derivation path") + + if script_type not in ['p2wsh', 'p2sh-p2wsh', 'p2wsh-p2sh']: + raise Exception(f"bad or missing script format") + + # create descriptor string + + if script_type == "p2wsh": + script_open = "wsh(sortedmulti(" + str(m) + script_close = "))" + elif script_type in ["p2sh-p2wsh", 'p2wsh-p2sh']: + script_open = "sh(wsh(sortedmulti(" + str(m) + script_close = ")))" + + descriptor = script_open + + for x in xpubs: + if derivation[0] == 'm': + derivation = derivation[1:] + derivation = derivation.replace("'", "h") + descriptor += ',[' + x['xfp'] + derivation + "]" + x['key'] + "/{0,1}/*" + + descriptor += script_close + + return descriptor class BaseQrDecoder: def __init__(self): @@ -551,51 +670,6 @@ class SpecterPsbtQrDecoder(BaseAnimatedQrDecoder): def parse_segment(self, segment) -> str: return segment.split(" ")[-1].strip() - - -class LegacyUrPsbtQrDecoder(BaseAnimatedQrDecoder): - """ - Decodes Legacy UR animated qr encoding - """ - def get_base64_data(self) -> str: - bc32_cbor = "".join(self.segments) - raw = cbor_decode(bc32decode(bc32_cbor)) - base64 = b2a_base64(raw) - - if self.complete: - return base64 - - return None - - - def get_data(self): - if not self.complete: - return None - - bc32_cbor = "".join(self.segments) - raw = cbor_decode(bc32decode(bc32_cbor)) - return raw - - - def parse_current_segment_num(self, segment) -> int: - if re.search(r'^UR:BYTES/(\d+)OF(\d+)', segment, re.IGNORECASE) != None: - return int(re.search(r'^UR:BYTES/(\d+)OF(\d+)', segment, re.IGNORECASE).group(1)) - else: - raise Exception('Unexpected Legacy UR Error') - - - def parse_total_segment_nums(self, segment) -> int: - if re.search(r'^UR:BYTES/(\d+)OF(\d+)', segment, re.IGNORECASE) != None: - return int(re.search(r'^UR:BYTES/(\d+)OF(\d+)', segment, re.IGNORECASE).group(2)) - else: - return 1 - - - def parse_segment(self, segment) -> str: - return segment.split("/")[-1].strip() - - - class Base64PsbtQrDecoder(BaseSingleFrameQrDecoder): """ Decodes single frame base64 encoded qr image. @@ -1005,6 +1079,7 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder): # Validate via embit Descriptor.from_string(segment) self.descriptor = segment + self.complete = True return DecodeQRStatus.COMPLETE except Exception as e: print(repr(e)) @@ -1013,4 +1088,11 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder): def get_wallet_descriptor(self): return self.descriptor + +class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder): + + def add(self, segment, qr_type=QRType.WALLET__CONFIGFILE): + descriptor = DecodeQR.multisig_setup_file_to_descriptor(segment) + return super().add(descriptor,qr_type=QRType.WALLET__CONFIGFILE) + diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index b4d15c6e..7ef0ca67 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -4,7 +4,6 @@ class QRType: """ PSBT__BASE64 = "psbt__base64" PSBT__SPECTER = "psbt__specter" - PSBT__LEGACY_UR = "psbt__legacy_ur" PSBT__BASE43 = "psbt__base43" PSBT__UR2 = "psbt__ur2" @@ -24,7 +23,10 @@ class QRType: WALLET__SPECTER = "wallet__specter" WALLET__UR = "wallet__ur" - WALLET__BLUEWALLET = "wallet__bluewallet" + WALLET__CONFIGFILE = "wallet__configfile" WALLET__GENERIC = "wallet__generic" + OUTPUT__UR = "output__ur" + ACCOUNT__UR = "account__ur" + BYTES__UR = "bytes__ur" INVALID = "invalid" \ No newline at end of file