diff --git a/hwilib/_cli.py b/hwilib/_cli.py index e0afa7dd2..3d14600d9 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -12,6 +12,7 @@ getdescriptors, prompt_pin, toggle_passphrase, + register_descriptor, restore_device, send_pin, setup_device, @@ -105,6 +106,9 @@ def send_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> def install_udev_rules_handler(args: argparse.Namespace) -> Dict[str, bool]: return install_udev_rules('udev', args.location) +def register_descriptor_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + return register_descriptor(client, args.name, args.descriptor) + class HWIHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): pass @@ -225,6 +229,11 @@ def get_parser() -> HWIArgumentParser: sendpin_parser.add_argument('pin', help='The numeric positions of the PIN') sendpin_parser.set_defaults(func=send_pin_handler) + register_descriptor_parser = subparsers.add_parser("registerdescriptor", help="Register a descriptor with the device") + register_descriptor_parser.add_argument("name", help="A human readable name for this descriptor. May be used by the device to identify the descriptor being used.") + register_descriptor_parser.add_argument("descriptor", help="The descriptor to register.") + register_descriptor_parser.set_defaults(func=register_descriptor_handler) + if sys.platform.startswith("linux"): udevrules_parser = subparsers.add_parser('installudevrules', help='Install and load the udev rule files for the hardware wallet devices') udevrules_parser.add_argument('--location', help='The path where the udev rules files will be copied', default='/etc/udev/rules.d/') diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..1730f9ffc 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -27,7 +27,6 @@ get_bip44_purpose, get_bip44_chain, H_, - HARDENED_FLAG, is_hardened, KeyOriginInfo, parse_path, @@ -328,18 +327,13 @@ def getdescriptor( origin = KeyOriginInfo(master_fpr, parsed_path[:i]) path_base = origin.get_derivation_path() - path_suffix = "" - for p in parsed_path[i:]: - hardened = is_hardened(p) - p &= ~HARDENED_FLAG - path_suffix += "/{}{}".format(p, "h" if hardened else "") - path_suffix += "/*" + path_suffix = [[p] for p in parsed_path[i:]] # Get the key at the base if client.xpub_cache.get(path_base) is None: client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base).to_string() - pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix) + pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix, 0, True) if addr_type is AddressType.LEGACY: return PKHDescriptor(pubkey) elif addr_type is AddressType.SH_WIT: @@ -590,3 +584,16 @@ def install_udev_rules(source: str, location: str) -> Dict[str, bool]: from .udevinstaller import UDevInstaller return {"success": UDevInstaller.install(source, location)} raise NotImplementedError("udev rules are not needed on your platform") + + +def register_descriptor(client: HardwareWalletClient, name: str, descriptor: str) -> Dict[str, str]: + """ + Register a descriptor with the device. + + Returns any information that will be needed in the future to inform the + device about the descriptor to sign with. Some devices do not require any + information and will return nothing. + + :return: A dictionary with the ``registration`` key containing a string + """ + return {"registration": client.register_descriptor(name, parse_descriptor(descriptor)).serialize()} diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 4e743fda7..0e8531179 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -10,12 +10,27 @@ """ -from .key import ExtendedKey, KeyOriginInfo, parse_path +from .key import ( + ExtendedKey, + KeyOriginInfo, + parse_multipath, + multipath_to_string, + path_to_string, +) from .common import hash160, sha256 +from .errors import BadArgumentError, InvalidPolicyError +from ._serialize import ( + deser_compact_size, + deser_string, + ser_compact_size, + ser_string, +) +from base64 import b64decode, b64encode from binascii import unhexlify from collections import namedtuple from enum import Enum +from io import BufferedReader, BytesIO from typing import ( List, Optional, @@ -103,16 +118,22 @@ def __init__( self, origin: Optional['KeyOriginInfo'], pubkey: str, - deriv_path: Optional[str] + deriv_path: Optional[List[List[int]]], + expr_index: int, + ranged: bool ) -> None: """ :param origin: The key origin if one is available :param pubkey: The public key. Either a hex string or a serialized extended pubkey :param deriv_path: Additional derivation path if the pubkey is an extended pubkey + :param expr_index: The position of this key within the descriptor """ self.origin = origin self.pubkey = pubkey self.deriv_path = deriv_path + self.expr_index = expr_index + self.ranged = ranged + self.multipath_len = max([len(p) for p in self.deriv_path]) if self.deriv_path is not None and len(self.deriv_path) > 0 else 1 # Make ExtendedKey from pubkey if it isn't hex self.extkey = None @@ -124,15 +145,17 @@ def __init__( self.extkey = ExtendedKey.deserialize(self.pubkey) @classmethod - def parse(cls, s: str) -> 'PubkeyProvider': + def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': """ Deserialize a key expression from the string into a ``PubkeyProvider``. :param s: String containing the key expression + :param key_expr_index: The position of this key within the descriptor :return: A new ``PubkeyProvider`` containing the details given by ``s`` """ origin = None deriv_path = None + ranged = False if s[0] == "[": end = s.index("]") @@ -143,9 +166,14 @@ def parse(cls, s: str) -> 'PubkeyProvider': slash_idx = s.find("/") if slash_idx != -1: pubkey = s[:slash_idx] - deriv_path = s[slash_idx:] + path_str = s[slash_idx + 1:] + ranged = path_str.endswith("*") + if ranged: + path_str = path_str[:-2] + if len(path_str) > 0: + deriv_path = parse_multipath(path_str) - return cls(origin, pubkey, deriv_path) + return cls(origin, pubkey, deriv_path, key_expr_index, ranged) def to_string(self, hardened_char: str = "h") -> str: """ @@ -158,53 +186,90 @@ def to_string(self, hardened_char: str = "h") -> str: s += "[{}]".format(self.origin.to_string(hardened_char)) s += self.pubkey if self.deriv_path: - s += self.deriv_path + s += multipath_to_string(self.deriv_path, hardened_char) + if self.ranged: + s += "/*" return s - def get_pubkey_bytes(self, pos: int) -> bytes: + def get_deriv_path(self, pos: int, multipath_pos: int) -> List[int]: + path = [] + if self.deriv_path: + for p in self.deriv_path: + if len(p) == 1: + path.append(p[0]) + else: + path.append(p[multipath_pos]) + if self.ranged: + path.append(pos) + return path + + def get_pubkey_bytes(self, pos: int, multipath_pos: int = 0) -> bytes: if self.extkey is not None: if self.deriv_path is not None: - path_str = self.deriv_path[1:] - if path_str[-1] == "*": - path_str = path_str[:-1] + str(pos) - path = parse_path(path_str) + path = self.get_deriv_path(pos, multipath_pos) child_key = self.extkey.derive_pub_path(path) return child_key.pubkey else: return self.extkey.pubkey return unhexlify(self.pubkey) - def get_full_derivation_path(self, pos: int) -> str: + def get_full_derivation_path(self, pos: int, multipath_pos: int = 0) -> str: """ Returns the full derivation path at the given position, including the origin """ path = self.origin.get_derivation_path() if self.origin is not None else "m/" - path += self.deriv_path if self.deriv_path is not None else "" - if path[-1] == "*": - path = path[:-1] + str(pos) + if self.deriv_path: + path += path_to_string(self.get_deriv_path(pos, multipath_pos)) + if self.ranged: + path += str(pos) return path - def get_full_derivation_int_list(self, pos: int) -> List[int]: + def get_full_derivation_int_list(self, pos: int, multipath_pos: int = 0) -> List[int]: """ Returns the full derivation path as an integer list at the given position. Includes the origin and master key fingerprint as an int """ path: List[int] = self.origin.get_full_int_list() if self.origin is not None else [] - if self.deriv_path is not None: - der_split = self.deriv_path.split("/") - for p in der_split: - if not p: - continue - if p == "*": - i = pos - elif p[-1] in "'phHP": - assert len(p) >= 2 - i = int(p[:-1]) | 0x80000000 - else: - i = int(p) - path.append(i) + if self.deriv_path: + path.extend(self.get_deriv_path(pos, multipath_pos)) + if self.ranged: + path.append(pos) return path + def get_bip388_placeholder(self) -> str: + """ + Get the key placeholder expression for this pubkey to be used in BIP 388 Wallet Policies. + The descriptor will be first checked for whether it likely confirms to BIP 388. Specifically: + + - All pubkeys must be ranged + - All multipath specifiers must be exactly 2 items. + + :return: The key placeholder expression + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + if not self.ranged: + raise InvalidPolicyError("BIP 388 requires all pubkeys to be ranged") + if self.multipath_len > 2: + raise InvalidPolicyError("BIP 388 requires all multipath specifiers to be exactly 2 elements") + deriv_path = multipath_to_string(self.deriv_path, hardened_char="'") if self.deriv_path else "" + if self.ranged: + deriv_path += "/*" + return f"@{self.expr_index}{deriv_path}" + + def get_bip388_key_info(self) -> str: + """ + Serialize the pubkey expression to a string without the trailing derivation path. + Used in the Key information vector of BIP 388 Wallet Policies. + + :return: The pubkey expression without trailing derivaiton path as a string + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + s = "" + if self.origin: + s += "[{}]".format(self.origin.to_string("'")) + s += self.pubkey + return s + def __lt__(self, other: 'PubkeyProvider') -> bool: return self.pubkey < other.pubkey @@ -255,6 +320,40 @@ def expand(self, pos: int) -> "ExpandedScripts": """ raise NotImplementedError("The Descriptor base class does not implement this method") + def get_bip388_template(self) -> str: + """ + Get the BIP 388 Wallet Descriptor Template string for this descriptor. + + Some BIP 388 specified checks are performed to determine. See ``get_bip388_placeholder()`` for the + pubkey specific checks that are performed. + + Note that not all BIP 388 specified checks are performed, specifically the following checks are not performed: + + - Duplicate keys check + - Disjoint multipath check + + :return: The template string + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + return "{}({}{})".format( + self.name, + ",".join([p.get_bip388_placeholder() for p in self.pubkeys]), + self.subdescriptors[0].get_bip388_template() if len(self.subdescriptors) > 0 else "" + ) + + def get_pubkey_providers(self) -> list['PubkeyProvider']: + """ + Get the strings of all pubkey expressions contained in this descriptor, + in the same order that they appear in the descriptor string. These can be used with + :func:`get_bip388_template` to get a full BIP 388 Wallet Policy for this descriptor. + + :return: List of pubkey expression strings + """ + out = [p for p in self.pubkeys] + for s in self.subdescriptors: + out.extend(s.get_pubkey_providers()) + return out + class PKDescriptor(Descriptor): """ @@ -324,8 +423,6 @@ def __init__( super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi") self.thresh = thresh self.is_sorted = is_sorted - if self.is_sorted: - self.pubkeys.sort() def to_string_no_checksum(self, hardened_char: str = "h") -> str: return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string(hardened_char) for p in self.pubkeys])) @@ -346,6 +443,9 @@ def expand(self, pos: int) -> "ExpandedScripts": return ExpandedScripts(script, None, None) + def get_bip388_template(self) -> str: + return "{}({},{})".format(self.name, self.thresh, ",".join([p.get_bip388_placeholder() for p in self.pubkeys])) + class SHDescriptor(Descriptor): """ @@ -424,6 +524,26 @@ def to_string_no_checksum(self, hardened_char: str = "h") -> str: r += ")" return r + def get_bip388_template(self) -> str: + r = f"{self.name}({self.pubkeys[0].get_bip388_placeholder()}" + path: List[bool] = [] # Track left or right for each depth + for p, depth in enumerate(self.depths): + r += "," + while len(path) <= depth: + if len(path) > 0: + r += "{" + path.append(False) + r += self.subdescriptors[p].get_bip388_template() + while len(path) > 0 and path[-1]: + if len(path) > 0: + r += "}" + path.pop() + if len(path) > 0: + path[-1] = True + r += ")" + return r + + def _get_func_expr(s: str) -> Tuple[str, str]: """ Get the function name and then the expression inside @@ -477,12 +597,13 @@ def _get_expr(s: str) -> Tuple[str, str]: return s, "" return s[0:i], s[i:] -def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]: +def parse_pubkey(expr: str, key_expr_index: int) -> Tuple['PubkeyProvider', str, int]: """ Parses an individual pubkey expression from a string that may contain more than one pubkey expression. :param expr: The expression to parse a pubkey expression from - :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item. + :param key_expr_index: The position of the next key to be parsed + :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, the remainder of the expression as the second item, and the index of the next key expression as the third. """ end = len(expr) comma_idx = expr.find(",") @@ -490,7 +611,7 @@ def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]: if comma_idx != -1: end = comma_idx next_expr = expr[end + 1:] - return PubkeyProvider.parse(expr[:end]), next_expr + return PubkeyProvider.parse(expr[:end], key_expr_index), next_expr, (key_expr_index + 1) class _ParseDescriptorContext(Enum): @@ -514,7 +635,7 @@ class _ParseDescriptorContext(Enum): """Within a ``tr()`` descriptor""" -def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor': +def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: int) -> Tuple['Descriptor', int]: """ :meta private: @@ -523,22 +644,23 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' :param desc: The descriptor string to parse :param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in - :return: The parsed descriptor + :param key_expr_index: The position of the next key to be parsed within the descriptor + :return: The parsed descriptor as the first item, and the index of the next key expression as the second. :raises: ValueError: if the descriptor is malformed """ func, expr = _get_func_expr(desc) if func == "pk": - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("more than one pubkey in pk descriptor") - return PKDescriptor(pubkey) + return PKDescriptor(pubkey), key_expr_index if func == "pkh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): raise ValueError("Can only have pkh at top level, in sh(), or in wsh()") - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") - return PKHDescriptor(pubkey) + return PKHDescriptor(pubkey), key_expr_index if func == "sortedmulti" or func == "multi": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()") @@ -547,8 +669,14 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' thresh = int(expr[:comma_idx]) expr = expr[comma_idx + 1:] pubkeys = [] + multipath_len = None while expr: - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) + if pubkey.multipath_len > 1: + if multipath_len is None: + multipath_len = pubkey.multipath_len + elif multipath_len != pubkey.multipath_len: + raise ValueError("Mismatched multipath paths") pubkeys.append(pubkey) if len(pubkeys) == 0 or len(pubkeys) > 16: raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 keys, inclusive".format(len(pubkeys))) @@ -558,28 +686,31 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys))) if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3: raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys") - return MultisigDescriptor(pubkeys, thresh, is_sorted) + return MultisigDescriptor(pubkeys, thresh, is_sorted), key_expr_index if func == "wpkh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): raise ValueError("Can only have wpkh() at top level or inside sh()") - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") - return WPKHDescriptor(pubkey) + return WPKHDescriptor(pubkey), key_expr_index if func == "sh": if ctx != _ParseDescriptorContext.TOP: raise ValueError("Can only have sh() at top level") - subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2SH) - return SHDescriptor(subdesc) + subdesc, key_expr_index = _parse_descriptor(expr, _ParseDescriptorContext.P2SH, key_expr_index) + return SHDescriptor(subdesc), key_expr_index if func == "wsh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): raise ValueError("Can only have wsh() at top level or inside sh()") - subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH) - return WSHDescriptor(subdesc) + subdesc, key_expr_index = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH, key_expr_index) + return WSHDescriptor(subdesc), key_expr_index if func == "tr": if ctx != _ParseDescriptorContext.TOP: raise ValueError("Can only have tr at top level") - internal_key, expr = parse_pubkey(expr) + multipath_len = None + internal_key, expr, key_expr_index = parse_pubkey(expr, key_expr_index) + if internal_key.multipath_len > 1: + multipath_len = internal_key.multipath_len subscripts = [] depths = [] if expr: @@ -599,7 +730,14 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' raise ValueError("tr() supports at most {MAX_TAPROOT_NODES} nesting levels") # Process script expression sarg, expr = _get_expr(expr) - subscripts.append(_parse_descriptor(sarg, _ParseDescriptorContext.P2TR)) + subdesc, key_expr_index = _parse_descriptor(sarg, _ParseDescriptorContext.P2TR, key_expr_index) + for pub in subdesc.pubkeys: + if pub.multipath_len > 1: + if multipath_len is None: + multipath_len = pub.multipath_len + elif multipath_len != pub.multipath_len: + raise ValueError("Mismatched multipath paths") + subscripts.append(subdesc) depths.append(len(branches)) # Process closing braces while len(branches) > 0 and branches[-1]: @@ -612,7 +750,7 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' if len(branches) == 0: break - return TRDescriptor(internal_key, subscripts, depths) + return TRDescriptor(internal_key, subscripts, depths), key_expr_index if ctx == _ParseDescriptorContext.P2SH: raise ValueError("A function is needed within P2SH") elif ctx == _ParseDescriptorContext.P2WSH: @@ -636,4 +774,54 @@ def parse_descriptor(desc: str) -> 'Descriptor': computed = DescriptorChecksum(desc) if computed != checksum: raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed)) - return _parse_descriptor(desc, _ParseDescriptorContext.TOP) + return _parse_descriptor(desc, _ParseDescriptorContext.TOP, 0)[0] + +class RegisteredDescriptor: + """ + An object containing a policy that was registered with a device + """ + + REG_VERSION = 0x00 + REG_NAME = 0x01 + REG_DESCRIPTOR = 0x02 + REG_DEVICE_TYPE = 0x03 + REG_REGISTRATION = 0x04 + + def __init__(self, name: str, descriptor: Descriptor, device_type: str, registration: bytes) -> None: + self.version = 1 + self.name = name + self.descriptor = descriptor + self.device_type = device_type + self.registration = registration + + def serialize(self) -> str: + r = b"rdesc" + + r += ser_compact_size(self.version) + r += ser_string(self.name.encode()) + r += ser_string(self.descriptor.to_string().encode()) + r += ser_string(self.device_type.encode()) + r += ser_string(self.registration) + + return b64encode(r).decode() + + @classmethod + def deserialize(cls, policy: str) -> 'RegisteredDescriptor': + policy_bytes = b64decode(policy.strip()) + s = BufferedReader(BytesIO(policy_bytes)) # type: ignore + + magic = s.read(5) + if magic != b"rdesc": + raise BadArgumentError("Policy has invalid magic bytes") + + version = deser_compact_size(s) + + if version != 1: + raise BadArgumentError("Serialized policy registration has unknown version number") + + name = deser_string(s).decode() + descriptor = parse_descriptor(deser_string(s).decode()) + device_type = deser_string(s).decode() + registration = deser_string(s) + + return cls(name, descriptor, device_type, registration) diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py index cc6b783b3..67f7209b8 100644 --- a/hwilib/devices/bitbox02.py +++ b/hwilib/devices/bitbox02.py @@ -23,7 +23,11 @@ from functools import wraps from .._base58 import decode_check, encode_check -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..key import ExtendedKey from .._script import ( @@ -423,7 +427,7 @@ def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: return xpub def _maybe_register_script_config( - self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int] + self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int], name: str = "" ) -> None: bb02 = self.init() is_registered = bb02.btc_is_script_config_registered( @@ -434,7 +438,7 @@ def _maybe_register_script_config( coin=self._get_coin(), script_config=script_config, keypath=keypath, - name="", # enter name on the device + name=name, # Default empty string means enter name on the device xpub_type=bitbox02.btc.BTCRegisterScriptConfigRequest.AUTO_XPUB_TPUB, ) @@ -526,7 +530,7 @@ def display_multisig_address( if not multisig.is_sorted: raise BadArgumentError("BitBox02 only supports sortedmulti descriptors") - path_suffixes = set(p.deriv_path for p in multisig.pubkeys) + path_suffixes = set(p.get_deriv_path(0, 0) for p in multisig.pubkeys) if len(path_suffixes) != 1: # Path suffix refers to the path after the account-level xpub, usually //
. # The BitBox02 currently enforces that all of them are the same. @@ -962,3 +966,23 @@ def can_sign_taproot(self) -> bool: :returns: False, always """ return False + + def _bip388_script_config(self, descriptor: Descriptor) -> bitbox02.btc.BTCScriptConfig: + desc_keys = [] + for pk in descriptor.get_pubkey_providers(): + desc_keys.append(bitbox02.common.KeyOriginInfo( + root_fingerprint=pk.origin.fingerprint if pk.origin else b"", + keypath=pk.origin.path if pk.origin else None, + xpub=util.parse_xpub(pk.pubkey) + )) + policy = bitbox02.btc.BTCScriptConfig.Policy( + policy=descriptor.get_bip388_template(), + keys=desc_keys, + ) + return bitbox02.btc.BTCScriptConfig(policy=policy) + + @bitbox02_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + script_config = self._bip388_script_config(descriptor) + self._maybe_register_script_config(script_config, [], name) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="bitbox02", registration=b"") diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index 1efe889b9..4f5e5586d 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -9,7 +9,11 @@ Union, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -59,6 +63,7 @@ import base64 import hid import io +import json import sys import time import struct @@ -415,6 +420,42 @@ def can_sign_taproot(self) -> bool: """ return self.is_edge + @coldcard_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + conf = { + "desc": descriptor.to_string(), + "name": name + } + conf_bytes = json.dumps(conf).encode() + + stream = io.BytesIO(conf_bytes) + size = len(conf_bytes) + + # Send the descriptor bytes + left = size + check = sha256() + for pos in range(0, size, MAX_BLK_LEN): + here = stream.read(min(MAX_BLK_LEN, left)) + if not here: + break + left -= len(here) + result = self.device.send_recv(CCProtocolPacker.upload(pos, size, here)) + assert result == pos + check.update(here) + + # verify the send + expect = check.digest() + result = self.device.send_recv(CCProtocolPacker.sha256()) + assert len(result) == 32 + if result != expect: + raise DeviceFailureError(f"Wrong checksum, expected {expect.hex()}, got {result.hex()}") + + # Register the descriptor + self.device.send_recv(CCProtocolPacker.multisig_enroll(size, expect), timeout=None) + if self.device.is_simulator: + self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="coldcard", registration=b"") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = True) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index 2733d0a2e..efab96e83 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -32,7 +32,11 @@ Chain, hash256, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -678,6 +682,14 @@ def can_sign_taproot(self) -> bool: """ return False + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The BitBox02 does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The Digital Bitbox does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 2530ebb34..91848bb36 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -20,7 +20,11 @@ Tuple, Union ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -437,8 +441,7 @@ def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDes 'path': []}) # Instead hold it as the address path - path = pubkey.deriv_path[1:] if pubkey.deriv_path[0] == '/' else pubkey.deriv_path - paths.append(parse_path(path)) + paths.append(pubkey.get_deriv_path(0, 0)) if multisig.is_sorted and paths[:-1] != paths[1:]: logging.warning('Sorted multisig with different derivations per signer') @@ -531,6 +534,13 @@ def can_sign_taproot(self) -> bool: """ return False + @jade_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + template = descriptor.get_bip388_template() + datavalues = {f"@{p.expr_index}": p.get_bip388_key_info() for p in descriptor.get_pubkey_providers()} + self.jade.register_descriptor(self._network(), name, template, datavalues) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="jade", registration=b"") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/keepkey.py b/hwilib/devices/keepkey.py index 21845e955..aefab5cc2 100644 --- a/hwilib/devices/keepkey.py +++ b/hwilib/devices/keepkey.py @@ -4,11 +4,16 @@ """ from ..common import Chain +from ..descriptor import ( + Descriptor, + RegisteredDescriptor, +) from ..errors import ( DEVICE_NOT_INITIALIZED, DeviceNotReadyError, common_err_msgs, handle_errors, + UnavailableActionError, ) from .trezorlib import protobuf from .trezorlib.transport import ( @@ -171,6 +176,14 @@ def can_sign_taproot(self) -> bool: """ return False + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The KeepKey does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The KeepKey does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index fdb6b6d82..b4df7140b 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -15,8 +15,10 @@ ) from ..descriptor import ( + Descriptor, MultisigDescriptor, PubkeyProvider, + RegisteredDescriptor, ) from ..hwwclient import HardwareWalletClient from ..errors import ( @@ -288,7 +290,7 @@ def legacy_sign_tx() -> PSBT: for xpub_bytes, xpub_origin in psbt2.xpub.items(): xpub = ExtendedKey.from_bytes(xpub_bytes) if (xpub_origin.fingerprint == pk_origin.fingerprint) and (xpub_origin.path == pk_origin.path[:len(xpub_origin.path)]): - key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None).to_string(hardened_char="'")) + key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None, 0, False).to_string(hardened_char="'")) break else: # No xpub, Ledger will not accept this multisig @@ -406,7 +408,7 @@ def _get_singlesig_default_wallet_policy(self, addr_type: AddressType, account: # Build a PubkeyProvider for the key we're going to use origin = KeyOriginInfo(self.get_master_fingerprint(), path) - pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None) + pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None, 0, False) key_str = pk_prov.to_string(hardened_char="'") # Make the Wallet object @@ -451,13 +453,12 @@ def display_multisig_address( if isinstance(self.client, LegacyClient): raise BadArgumentError("Displaying multisignature addresses is not supported by this version of the Bitcoin App") - def is_valid_der_path(path: Optional[str]) -> bool: - if path is None: + def is_valid_der_path(pk: PubkeyProvider) -> bool: + if pk.deriv_path is None: return False - path_parts = path.split("/") - return len(path_parts) == 3 and path_parts[1] in ["0", "1"] and path_parts[2].isdigit() and 0 <= int(path_parts[2]) <= 0x7fffffff + return len(pk.deriv_path) == 3 and pk.deriv_path[1] in [[0], [1]] and 0 <= pk.deriv_path[2][0] <= 0x7fffffff and pk.ranged - if any(not is_valid_der_path(pk.deriv_path) for pk in multisig.pubkeys): + if any(not is_valid_der_path(pk) for pk in multisig.pubkeys): raise BadArgumentError("Ledger Bitcoin app requires derivation paths ending with /0/* or /1/* for multisig") if not (all(pk.deriv_path == multisig.pubkeys[0].deriv_path for pk in multisig.pubkeys)): @@ -477,8 +478,8 @@ def format_key_info(pubkey: PubkeyProvider) -> str: _, registered_hmac = self.client.register_wallet(multisig_wallet) assert multisig.pubkeys[0].deriv_path is not None # already checked above with is_valid_der_path - change = 0 if multisig.pubkeys[0].deriv_path[:3] == "/0/" else 1 - address_index = int(multisig.pubkeys[0].deriv_path.split("/")[2]) + change = 0 if multisig.pubkeys[0].deriv_path[3] == [0] else 1 + address_index = int(multisig.pubkeys[0].deriv_path[2][0]) return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) @@ -550,6 +551,17 @@ def can_sign_taproot(self) -> bool: """ return isinstance(self.client, NewClient) + @ledger_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + if isinstance(self.client, LegacyClient): + raise UnavailableActionError("Legacy Ledger app does not support descriptor registration") + + template = descriptor.get_bip388_template() + keys = [p.get_bip388_key_info() for p in descriptor.get_pubkey_providers()] + policy = WalletPolicy(name, template, keys) + _, registered_hmac = self.client.register_wallet(policy) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="ledger", registration=registered_hmac) + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index bd5a79733..89e1b58ce 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -16,7 +16,11 @@ Tuple, Union, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -712,7 +716,7 @@ def display_multisig_address( if p.extkey is not None: xpub = p.extkey hd_node = messages.HDNodeType(depth=xpub.depth, fingerprint=int.from_bytes(xpub.parent_fingerprint, 'big'), child_num=xpub.child_num, chain_code=xpub.chaincode, public_key=xpub.pubkey) - pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=parse_path("m" + p.deriv_path if p.deriv_path is not None else ""))) + pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=p.get_deriv_path(0, 0))) else: hd_node = messages.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=pk) pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=[])) @@ -730,8 +734,7 @@ def display_multisig_address( raise BadArgumentError("Unknown address type") for p in multisig.pubkeys: - keypath = p.origin.get_derivation_path() if p.origin is not None else "m/" - keypath += p.deriv_path if p.deriv_path is not None else "" + keypath = p.get_full_derivation_path(0) path = parse_path(keypath) try: address = btc.get_address( @@ -850,6 +853,14 @@ def can_sign_taproot(self) -> bool: return bool(self.client.version >= (1, 10, 4)) return True + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The Trezor does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The Trezor does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/errors.py b/hwilib/errors.py index 1f8c9195d..2c5cbde33 100644 --- a/hwilib/errors.py +++ b/hwilib/errors.py @@ -31,6 +31,7 @@ NEED_TO_BE_ROOT = -16 #: User needs to be root to perform action HELP_TEXT = -17 #: Help text was requested by the user DEVICE_NOT_INITIALIZED = -18 #: Device is not initialized +INVALID_POLICY = -19 #: Supplied descriptor cannot become a valid BIP 388 wallet policy # Exceptions class HWWError(Exception): @@ -204,6 +205,16 @@ class NeedsRootError(HWWError): def __init__(self, msg: str): HWWError.__init__(self, msg, NEED_TO_BE_ROOT) +class InvalidPolicyError(HWWError): + """ + :class:`HWWError` for :data:`INVALID_POLICY` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, INVALID_POLICY) + @contextmanager def handle_errors( msg: Optional[str] = None, diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index 565afcf44..394bd837f 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -10,7 +10,11 @@ Optional, Union, ) -from .descriptor import MultisigDescriptor +from .descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from .key import ( ExtendedKey, get_bip44_purpose, @@ -237,3 +241,13 @@ def can_sign_taproot(self) -> bool: """ raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") + + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + Register a descriptor with the device + + :return: The registration result string + :raises UnavilableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") diff --git a/hwilib/key.py b/hwilib/key.py index cb89e3926..506fb1aad 100644 --- a/hwilib/key.py +++ b/hwilib/key.py @@ -281,14 +281,7 @@ def serialize(self) -> bytes: return r def _path_string(self, hardened_char: str = "h") -> str: - s = "" - for i in self.path: - hardened = is_hardened(i) - i &= ~HARDENED_FLAG - s += "/" + str(i) - if hardened: - s += hardened_char - return s + return path_to_string(self.path, hardened_char) def to_string(self, hardened_char: str = "h") -> str: """ @@ -330,16 +323,7 @@ def get_full_int_list(self) -> List[int]: return xfp -def parse_path(nstr: str) -> List[int]: - """ - Convert BIP32 path string to list of uint32 integers with hardened flags. - Several conventions are supported to set the hardened flag: -1, 1', 1h - - e.g.: "0/1h/1" -> [0, 0x80000001, 1] - - :param nstr: path string - :return: list of integers - """ +def _parse_path(nstr: str, allow_multipath: bool) -> List[List[int]]: if not nstr: return [] @@ -357,10 +341,95 @@ def str_to_harden(x: str) -> int: else: return int(x) + def parse_index(x: str, seen_multipath: bool) -> Tuple[List[int], bool]: + if x.startswith("<"): + if seen_multipath: + raise ValueError("Cannot have multiple multipath specifiers") + if not allow_multipath: + raise ValueError(f"Multipath specifier not allowed: {x}") + if not x.endswith(">"): + raise ValueError(f"Invalid multipath specification, missing trailing '>': {x}") + mp = x[1:-1].split(";") + if len(mp) < 2: + raise ValueError(f"Invalid multipath specification, less than 2 indexes specified: {x}") + seen_multipath = True + return [str_to_harden(p) for p in mp], seen_multipath + return [str_to_harden(x)], seen_multipath + try: - return [str_to_harden(x) for x in n] + path = [] + seen_multipath = False + for x in n: + idx, seen_multipath = parse_index(x, seen_multipath) + path.append(idx) + return path + except ValueError as e: + raise e except Exception: - raise ValueError("Invalid BIP32 path", nstr) + raise ValueError(f"Invalid BIP32 path: {nstr}") + + +def parse_path(nstr: str) -> List[int]: + """ + Convert BIP32 path string to list of uint32 integers with hardened flags. + Several conventions are supported to set the hardened flag: -1, 1', 1h + + e.g.: "0/1h/1" -> [0, 0x80000001, 1] + + :param nstr: path string + :return: list of integers + :raises ValueError: If the path contains any invalid characters + """ + return [i[0] for i in _parse_path(nstr, False)] + + +def parse_multipath(s: str) -> List[List[int]]: + """ + Convert multipath BIP32 path strings as specified in BIP389 to a list of lists ints. + + :param s: path string + :return: list of lists of integers + :raises ValueError: If the path contains any invalid specifiers + """ + return _parse_path(s, True) + + +def _path_to_string(path: Sequence[Sequence[int]], hardened_char: str = "h") -> str: + def index_to_str(i: int) -> str: + hardened = is_hardened(i) + i &= ~HARDENED_FLAG + out = str(i) + if hardened: + out += hardened_char + return out + + s = "" + for mp in path: + if len(mp) == 1: + s += f"/{index_to_str(mp[0])}" + else: + s += f"/<{';'.join([index_to_str(p) for p in mp])}>" + return s + + +def path_to_string(path: Sequence[int], hardened_char: str = "h") -> str: + """ + Convert a list of ints specifying a BIP32 derivation path to a string + + :param path: Path as a list of ints + :return: String representing the path + """ + return _path_to_string([[i] for i in path], hardened_char) + + +def multipath_to_string(path: Sequence[Sequence[int]], hardened_char: str = "h") -> str: + """ + Convert a list of list of ints specifying a multipath BIP32 path to a string as specified by BIP389 + + :param path: The path as a list of list of ints + :return: String representing the path + """ + return _path_to_string(path, hardened_char) def get_bip44_purpose(addrtype: AddressType) -> int: diff --git a/test/data/coldcard-multisig.patch b/test/data/coldcard-multisig.patch deleted file mode 100644 index 05216356f..000000000 --- a/test/data/coldcard-multisig.patch +++ /dev/null @@ -1,114 +0,0 @@ -From 038e4b4f5e8128f3910745324332770b0973408c Mon Sep 17 00:00:00 2001 -From: Ava Chow -Date: Wed, 1 Oct 2025 13:30:01 -0700 -Subject: [PATCH 1/2] Change default simulator multisig - ---- - unix/variant/sim_settings.py | 6 +++++- - 1 file changed, 5 insertions(+), 1 deletion(-) - -diff --git a/unix/variant/sim_settings.py b/unix/variant/sim_settings.py -index 01392d8f..527d0d9b 100644 ---- a/unix/variant/sim_settings.py -+++ b/unix/variant/sim_settings.py -@@ -71,7 +71,11 @@ if '--ms' in sys.argv: - sim_defaults['multisig'] = [["CC-2-of-4", [2, 4], [[1130956047, "tpubDF2rnouQaaYrUEy2JM1YD3RFzew4onawGM4X2Re67gguTf5CbHonBRiFGe3Xjz7DK88dxBFGf2i7K1hef3PM4cFKyUjcbJXddaY9F5tJBoP"], [3503269483, "tpubDFcrvj5n7gyatVbr8dHCUfHT4CGvL8hREBjtxc4ge7HZgqNuPhFimPRtVg6fRRwfXiQthV9EBjNbwbpgV2VoQeL1ZNXoAWXxP2L9vMtRjax"], [2389277556, "tpubDExj5FnaUnPAjjgzELoSiNRkuXJG8Cm1pbdiA4Hc5vkAZHphibeVcUp6mqH5LuNVKbtLVZxVSzyja5X26Cfmx6pzRH6gXBUJAH7MiqwNyuM"], [3190206587, "tpubDFiuHYSJhNbHaGtB5skiuDLg12tRboh2uVZ6KGXxr8WVr28pLcS7F3gv8SsHFa2tm1jtx3VAuw56YfgRkdo6DXyfp51oygTKY3nJFT5jBMt"]], {"pp": "48'/1'/0'/1'", "ch": "XTN", "ft": 26}]] - else: - # P2SH: 2of4 using BIP39 passwords: "Me", "Myself", "and I", and (empty string) on simulator -- sim_defaults['multisig'] = [['MeMyself', [2, 4], [[3503269483, 'tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9'], [2389277556, 'tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc'], [3190206587, 'tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa'], [1130956047, 'tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n']], {'ch': 'XTN', 'pp': "45'"}]] -+ sim_defaults['multisig'] = [ -+ ['mstest', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 8, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ['mstest1', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 14, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ['mstest2', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 26, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ] - sim_defaults['fee_limit'] = -1 - - if '--xfp' in sys.argv: --- -2.51.0 - - -From 3f7b6dfcf72788a4a0784eb871462c289e8a747b Mon Sep 17 00:00:00 2001 -From: Sjors Provoost -Date: Wed, 23 Jul 2025 10:16:22 +0200 -Subject: [PATCH 2/2] Allow multisigs to share master fingerprint - -Co-Authored-By: Ava Chow ---- - shared/multisig.py | 39 ++++++++++++++++++++++++--------------- - 1 file changed, 24 insertions(+), 15 deletions(-) - -diff --git a/shared/multisig.py b/shared/multisig.py -index 1e692d41..6294e9a3 100644 ---- a/shared/multisig.py -+++ b/shared/multisig.py -@@ -144,9 +144,9 @@ class MultisigWallet(WalletABC): - # calc useful cache value: numeric xfp+subpath, with lookup - self.xfp_paths = {} - for xfp, deriv, xpub in self.xpubs: -- self.xfp_paths[xfp] = str_to_keypath(xfp, deriv) -+ self.xfp_paths.setdefault(xfp, list()).append(str_to_keypath(xfp, deriv)) - -- assert len(self.xfp_paths) == self.N, 'dup XFP' # not supported -+ assert len(self.xpubs) == self.N, 'Number of pubkeys does not match N' - - @classmethod - def render_addr_fmt(cls, addr_fmt): -@@ -275,7 +275,11 @@ class MultisigWallet(WalletABC): - - def get_xfp_paths(self): - # return list of lists [xfp, *deriv] -- return list(self.xfp_paths.values()) -+ ret = [] -+ for paths_list in self.xfp_paths: -+ for xfp_path in paths_list: -+ ret.append(xfp_path) -+ return ret - - @classmethod - def find_match(cls, M, N, xfp_paths, addr_fmts=None): -@@ -305,24 +309,29 @@ class MultisigWallet(WalletABC): - # the same prefix path per-each xfp, as indicated - # xfp_paths (unordered)? - # - could also check non-prefix part is all non-hardened -- if len(xfp_paths) != len(self.xfp_paths): -+ if len(xfp_paths) != sum([len(x) for _, x in self.xfp_paths.items()]): - # cannot be the same if len(w0.N) != len(w1.N) - # maybe check duplicates first? - return False - for x in xfp_paths: - if x[0] not in self.xfp_paths: - return False -- prefix = self.xfp_paths[x[0]] -- -- if len(x) < len(prefix): -- # PSBT specs a path shorter than wallet's xpub -- #print('path len: %d vs %d' % (len(prefix), len(x))) -- return False -- -- comm = len(prefix) -- if tuple(prefix[:comm]) != tuple(x[:comm]): -- # xfp => maps to wrong path -- #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) -+ for prefix in self.xfp_paths[x[0]]: -+ if len(x) < len(prefix): -+ # PSBT specs a path shorter than wallet's xpub -+ #print('path len: %d vs %d' % (len(prefix), len(x))) -+ return False -+ -+ comm = len(prefix) -+ if tuple(prefix[:comm]) != tuple(x[:comm]): -+ # xfp => maps to wrong path -+ #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) -+ continue -+ else: -+ # Found a match, cleanly exit -+ break -+ else: -+ # No match was found - return False - - return True --- -2.51.0 - diff --git a/test/data/coldcard_xpubs.json b/test/data/coldcard_xpubs.json deleted file mode 100644 index 164807d16..000000000 --- a/test/data/coldcard_xpubs.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "master_xpub": "tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd", - "vectors" : [ - { - "path" : "m/0h", - "xpub" : "tpubD8NXmKsmWp3Y4WD8VqhKZ61UrvLfghEJjjHsJT8NYBPaHMVDVmCWGmttsn4HE6mEMSdCwcaGtB2iftGFZRcfeF2NxbpzSt2jpkYjdRQuX4W" - }, - { - "path" : "m/0h/1", - "xpub" : "tpubDBKU51v7tZU5KvJ34JHeQ5Rr2uRg8mJLr3JSRR68HPZXEtwubGyqXBDDUqJmUAHzb9h23zyAJVSbzrV6P5qCKfqu8gQfX1EpH16SBeshMXp" - }, - { - "path" : "m/0h/1/2h", - "xpub" : "tpubDD4Y2tN94L2KZiQ1tdpC5oUUATuWAwmRTuPchMY3HCkz2fexHBZDriebcpBYBcicEy1WTYAiALgemNXKd6jcuj9Vj4kUxhG3Uw9m8YgbcTn" - }, - { - "path" : "m/0h/1/2h/2", - "xpub" : "tpubDEt7ap9HgGHPeP5DJqTvuptUw6RoJXfX5bstN8CdSvFgwFa69Qh2mB3hwJ6PAGfvpckRwrazGCJbmeUYtfr5ysREdfqyQSU4UFqhktMCmDr" - }, - { - "path" : "m/0h/1/2h/2/1000000000", - "xpub" : "tpubDGV2stSVNpSZ7BPHmYYTqt4DXPaqKdqGMTX8ecS5Ae5tiVXd2NrqArPV8EBXzZ55y6u86SQF1i4eQ3JVYJbLdGYz3yTN8UrkJQrbgYjEeHH" - }, - { - "path" : "m/0", - "xpub" : "tpubD8NXmKsdB9WZs2HhkE7jEZLymyb5dyvjJg2d7gGJCadyaoogRhtcpTUWdxmvECgVUVpGrxxHJzYdKw6q79NtLL4mzNeqbk4h2B2sE8aisFE" - }, - { - "path" : "m/0/2147483647h", - "xpub" : "tpubDAogKPq6VDemCnuzg2ChNjDFtfiw2bG4Ja17GTxyAmohbNmz29Z7xRitwVdTrUPBdVF1EEuQBrAvUJ6m6kNFKSB723ws3RBxL8a67BnYNd3" - }, - { - "path" : "m/0/2147483647h/1", - "xpub" : "tpubDDAsHiKquEdhD3dpctEhMgrF3QVFKz2NeCS6kkdTcN6AszAyCppLbQDzTqdqzZV4N1guZyWmxpT59dUPWKuKtijGvETuHSbNQubNvZck3L7" - }, - { - "path" : "m/0/2147483647h/1/2147483646h", - "xpub" : "tpubDFNKqVRhj269YBcR4QHUeYHruqHQYCJkQX4K2oJG2S9EEEkvzP2fsEL5RB7zXBspZJNuxvv1qFUDafQQaN7j7Tv2g5U5gzb2YNshNZDR1Fx" - }, - { - "path" : "m/0/2147483647h/1/2147483646h/2", - "xpub" : "tpubDGRkLZjvcoBbAnQjmCcx5Crc6hcU2BBJbXFyX516GSEw68mj6NTB7W7sXnMNA3iVoxDPYTt2QbR3oP8cjxxgkjaN9mqrAP43VqASDa3W4Qz" - } - ] - } -] \ No newline at end of file diff --git a/test/data/speculos-automation.json b/test/data/speculos-automation.json index 41cc06129..5618fefb7 100644 --- a/test/data/speculos-automation.json +++ b/test/data/speculos-automation.json @@ -39,14 +39,7 @@ ] }, { - "regexp": "^. of . Multisig$", - "actions": [ - [ "button", 2, true ], - [ "button", 2, false ] - ] - }, - { - "regexp": "^(Address|Review|Amount|External amounts|You spend|You receive|Fee|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Our key|Their key|Unspendable key|Spend from|Spending policy|Primary spending path|Spending path|Transaction output|Wallet name|Wallet policy|Descriptor template|Verify [Bb]itcoin|Output|Warning).*", + "regexp": "^(Address|Review|Account name|Amount|External amounts|You spend|You receive|Fee|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Our key|Their key|Unspendable key|Spend from|Spending policy|Primary spending path|Spending path|Transaction output|From account|Wallet name|Wallet policy|Descriptor template|Verify [Bb]itcoin|Output|Warning).*", "actions": [ [ "button", 2, true ], [ "button", 2, false ] diff --git a/test/setup_environment.sh b/test/setup_environment.sh index ac76242d9..1ccb8cd7e 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -163,8 +163,6 @@ if [[ -n ${build_coldcard} ]]; then coldcard_setup_needed=true fi fi - # Apply patch to make simulator work in linux environments - git am ../../data/coldcard-multisig.patch # Build the simulator. This is cached, but it is also fast poetry run pip install -r requirements.txt diff --git a/test/test_bitbox02.py b/test/test_bitbox02.py index 400da4afa..b8bde6f63 100644 --- a/test/test_bitbox02.py +++ b/test/test_bitbox02.py @@ -18,6 +18,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignTx, ) @@ -41,9 +42,9 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = True - self.supports_device_multiple_multisig = True self.supports_legacy = False self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() @@ -128,6 +129,7 @@ def bitbox02_test_suite(simulator, bitcoind, interface): # TestSignMessage is removed, since its only testcase is for legacy p2pkh, which is not supported by BitBox02 # suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestBitbox02GetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False, sorted=False)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_coldcard.py b/test/test_coldcard.py index 4259d1efd..eb27984ed 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -19,6 +19,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -43,9 +44,9 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = True def start(self): super().start() @@ -162,6 +163,7 @@ def coldcard_test_suite(simulator, bitcoind, interface): suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_descriptor.py b/test/test_descriptor.py index 9e281a479..828e24616 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -9,6 +9,7 @@ WPKHDescriptor, WSHDescriptor, ) +from hwilib.errors import InvalidPolicyError from binascii import unhexlify @@ -22,7 +23,8 @@ def test_parse_descriptor_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -37,12 +39,14 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) @@ -56,12 +60,14 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("a91495ee6326805b1586bb821fc3c0eeab2c68441b4187")) @@ -76,12 +82,14 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("a914779ae0f6958e98b997cc177f9b554289905fbb5587")) @@ -94,7 +102,7 @@ def test_parse_descriptor_without_origin(self): self.assertTrue(isinstance(desc, WPKHDescriptor)) self.assertEqual(desc.pubkeys[0].origin, None) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -108,7 +116,8 @@ def test_parse_descriptor_with_origin_fingerprint_only(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(len(desc.pubkeys[0].origin.path), 0) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -123,6 +132,7 @@ def test_parse_descriptor_with_key_at_end_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -136,6 +146,7 @@ def test_parse_descriptor_with_key_at_end_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("76a914d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa88ac")) @@ -149,6 +160,7 @@ def test_parse_descriptor_with_key_at_end_without_origin(self): self.assertEqual(desc.pubkeys[0].origin, None) self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) def test_parse_empty_descriptor(self): @@ -191,7 +203,8 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)},pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)}})" @@ -201,7 +214,12 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) + self.assertEqual(desc.pubkeys[0].expr_index, 0) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) + self.assertEqual(desc.subdescriptors[1].pubkeys[0].expr_index, 2) + self.assertEqual(desc.subdescriptors[2].pubkeys[0].expr_index, 3) + self.assertEqual(desc.subdescriptors[3].pubkeys[0].expr_index, 4) self.assertEqual(desc.depths, [1, 3, 3, 2]) self.assertEqual(desc.to_string_no_checksum(), d) @@ -213,6 +231,125 @@ def test_tr_descriptor(self): self.assertEqual(desc.depths, [0]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "669b8afcec803a0d323e9a17f3ea8e68e8abe5a278020a929adbec52421adbd0") self.assertEqual(desc.to_string_no_checksum(), d) + self.assertEqual(desc.pubkeys[0].expr_index, 0) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) + + def test_multipath_descriptors(self): + d = "pk(xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/<0;1>)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0, 1]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "pkh(xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/<2147483647h;0>/0)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0xffffffff, 0], [0]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "wpkh([ffffffff/13h]xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/<1;3>/2/*)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[1, 3], [2]]) + self.assertTrue(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2>/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/<3;4>/0/*))" + desc = parse_descriptor(d) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[1, 2]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[0].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[3, 4], [0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[1].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "pkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0;1;2>)" + desc = parse_descriptor(d) + self.assertTrue(desc.pubkeys[0].deriv_path, [[0, 1, 2]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2;3>/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/<3;4;5>/*))" + desc = parse_descriptor(d) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[1, 2, 3], [0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[0].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[1].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[2].deriv_path, [[0], [0], [3, 4, 5]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[2].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + def test_invalid_multipath_descriptors(self): + with self.assertRaisesRegex(ValueError, "Cannot have multiple multipath specifiers"): + parse_descriptor("pkh(xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/<0;1>/<2;3>)") + with self.assertRaisesRegex(ValueError, "Multipath specifier not allowed"): + parse_descriptor("pkh([deadbeef/<0;1>]xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0)") + with self.assertRaisesRegex(ValueError, "Mismatched multipath paths"): + parse_descriptor("tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/<6;7;8;9>/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2;3>/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/<3;4;5>/*)})") + with self.assertRaisesRegex(ValueError, "Mismatched multipath paths"): + parse_descriptor("sh(multi(2,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/<1;2;3>/0/*,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0/*,xprv9s21ZrQH143K3jUwNHoqQNrtzJnJmx4Yup8NkNLdVQCymYbPbJXnPhwkfTfxZfptcs3rLAPUXS39oDLgrNKQGwbGsEmJJ8BU3RzQuvShEG4/0/0/<3;4>/*))") + with self.assertRaisesRegex(ValueError, "Invalid multipath specification, less than 2 indexes specified: <>"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<>/*)") + with self.assertRaisesRegex(ValueError, "invalid literal for int()"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0>/*)") + with self.assertRaisesRegex(ValueError, "Invalid multipath specification, missing trailing '>'"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0/*)") + with self.assertRaisesRegex(ValueError, "invalid literal for int()"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0;>/*)") + + def test_valid_bip388_conversion(self): + def check(descriptor, keys, template): + d = parse_descriptor(descriptor) + self.assertEqual(d.get_bip388_template(), template) + self.assertEqual([p.get_bip388_key_info() for p in d.get_pubkey_providers()], keys) + + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1>/*)", + ["[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + "pkh(@0/<0;1>/*)" + ) + check( + "sh(wpkh([6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9/<0;1>/*))", + ["[6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9"], + "sh(wpkh(@0/<0;1>/*))" + ) + check( + "wpkh([6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt/<0;1>/*)", + ["[6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt"], + "wpkh(@0/<0;1>/*)" + ) + check( + "tr([6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL/<0;1>/*)", + ["[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL"], + "tr(@0/<0;1>/*)" + ) + check( + "wsh(sortedmulti(2,[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw/<0;1>/*,[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7/<0;1>/*))", + ["[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw", "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7"], + "wsh(sortedmulti(2,@0/<0;1>/*,@1/<0;1>/*))" + ) + check( + "pkh([6738736c/44h/0h/0h]xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0h;1>/*)", + ["[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + "pkh(@0/<0';1>/*)" + ) + + def test_invalid_bip388_converstion(self): + def check(descriptor, error): + with self.assertRaisesRegex(InvalidPolicyError, error): + d = parse_descriptor(descriptor) + d.get_bip388_template() + + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1>)", + "BIP 388 requires all pubkeys to be ranged" + ) + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/0/1)", + "BIP 388 requires all pubkeys to be ranged" + ) + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1;2>/*)", + "BIP 388 requires all multipath specifiers to be exactly 2 elements" + ) if __name__ == "__main__": diff --git a/test/test_device.py b/test/test_device.py index af26c5c78..bd63f8465 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -16,7 +16,7 @@ from authproxy import AuthServiceProxy, JSONRPCException from hwilib._base58 import xpub_to_pub_hex, to_address, decode from hwilib._cli import process_commands -from hwilib.descriptor import AddChecksum, parse_descriptor, PubkeyProvider +from hwilib.descriptor import AddChecksum, parse_descriptor, PubkeyProvider, RegisteredDescriptor from hwilib.key import ExtendedKey, KeyOriginInfo from hwilib.psbt import PSBT @@ -34,9 +34,9 @@ def __init__(self): self.supports_taproot = None self.strict_bip48 = None self.include_xpubs = None - self.supports_device_multiple_multisig = None self.supports_legacy = None self.supports_arbitrary_keypool_paths = True + self.register_multisig = None def start(self): assert self.type is not None @@ -50,8 +50,8 @@ def start(self): assert self.supports_unsorted_ms is not None assert self.strict_bip48 is not None assert self.include_xpubs is not None - assert self.supports_device_multiple_multisig is not None assert self.supports_legacy is not None + assert self.register_multisig is not None def stop(self): pass @@ -364,10 +364,9 @@ def setUp(self): self.setup_wallets() def _generate_and_finalize(self, unknown_inputs, psbt): - if not self.emulator.supports_device_multiple_multisig: - # We will need Core to sign so that the multisig is complete - core_sign_res = self.wpk_rpc.walletprocesspsbt(psbt=psbt, finalize=False) - psbt = core_sign_res["psbt"] + # We will need Core to sign so that the multisig is complete + core_sign_res = self.wpk_rpc.walletprocesspsbt(psbt=psbt, finalize=False) + psbt = core_sign_res["psbt"] if not unknown_inputs: # Just do the normal signing process to test "all inputs" case @@ -446,43 +445,42 @@ def _make_multisig(self, addrtype): desc_pubkeys = [] xpubs: Dict[bytes, KeyOriginInfo] = {} - for account in range(0, 3 if self.emulator.supports_device_multiple_multisig else 1): - path = f"/48h/1h/{account}h/{coin_type}h" - origin = '{}{}'.format(self.emulator.fingerprint, path) - xpub = self.do_command(self.dev_args + ["getxpub", "m{}".format(path)]) - desc_pubkeys.append("[{}]{}/0/0".format(origin, xpub["xpub"])) - if self.emulator.include_xpubs: - extkey = ExtendedKey.deserialize(xpub["xpub"]) - xpubs[extkey.serialize()] = KeyOriginInfo.from_string(origin) - - if not self.emulator.supports_device_multiple_multisig: - # If the device does not support itself in the multisig more than once, - # we need to fetch a key from Core, and use another key that will not be signed with - counter_descs = self.wpk_rpc.listdescriptors()["descriptors"] - desc = parse_descriptor(counter_descs[0]["desc"]) - pubkey_prov = None - while pubkey_prov is None: - if len(desc.pubkeys) > 0: - pubkey_prov = desc.pubkeys[0] - else: - desc = desc.subdescriptors[0] - assert pubkey_prov.extkey is not None - assert pubkey_prov.origin is not None - pubkey_prov.deriv_path = "/0/0" - desc_pubkeys.append(pubkey_prov.to_string()) - if self.emulator.include_xpubs: - xpubs[pubkey_prov.extkey.serialize()] = pubkey_prov.origin - - # A fixed key - fixed_extkey = ExtendedKey.deserialize("tpubDCBWBScQPGv4Xk3JSbhw6wYYpayMjb2eAYyArpbSqQTbLDpphHGAetB6VQgVeftLML8vDSUEWcC2xDi3qJJ3YCDChJDvqVzpgoYSuT52MhJ") - fixed_origin = KeyOriginInfo(b"\xde\xad\xbe\xef", [0x80000000]) - desc_pubkeys.append(PubkeyProvider(fixed_origin, fixed_extkey.to_string(), "/0/0").to_string()) - if self.emulator.include_xpubs: - xpubs[fixed_extkey.serialize()] = fixed_origin + path = f"/48h/1h/0h/{coin_type}h" + origin = '{}{}'.format(self.emulator.fingerprint, path) + xpub = self.do_command(self.dev_args + ["getxpub", "m{}".format(path)]) + desc_pubkeys.append("[{}]{}/0/*".format(origin, xpub["xpub"])) + if self.emulator.include_xpubs: + extkey = ExtendedKey.deserialize(xpub["xpub"]) + xpubs[extkey.serialize()] = KeyOriginInfo.from_string(origin) + + # fetch a key from Core, and use another key that will not be signed with + counter_descs = self.wpk_rpc.listdescriptors()["descriptors"] + desc = parse_descriptor(counter_descs[0]["desc"]) + pubkey_prov = None + while pubkey_prov is None: + if len(desc.pubkeys) > 0: + pubkey_prov = desc.pubkeys[0] + else: + desc = desc.subdescriptors[0] + assert pubkey_prov.extkey is not None + assert pubkey_prov.origin is not None + pubkey_prov.deriv_path = [[0]] + pubkey_prov.ranged = True + pubkey_prov.expr_index = 1 + desc_pubkeys.append(pubkey_prov.to_string()) + if self.emulator.include_xpubs: + xpubs[pubkey_prov.extkey.serialize()] = pubkey_prov.origin + + # A fixed key + fixed_extkey = ExtendedKey.deserialize("tpubDCBWBScQPGv4Xk3JSbhw6wYYpayMjb2eAYyArpbSqQTbLDpphHGAetB6VQgVeftLML8vDSUEWcC2xDi3qJJ3YCDChJDvqVzpgoYSuT52MhJ") + fixed_origin = KeyOriginInfo(b"\xde\xad\xbe\xef", [0x80000000]) + desc_pubkeys.append(PubkeyProvider(fixed_origin, fixed_extkey.to_string(), [[0]], 2, True).to_string()) + if self.emulator.include_xpubs: + xpubs[fixed_extkey.serialize()] = fixed_origin desc = AddChecksum(f"{desc_prefix}sortedmulti(2,{desc_pubkeys[0]},{desc_pubkeys[1]},{desc_pubkeys[2]}){desc_suffix}") - return desc, self.rpc.deriveaddresses(desc)[0], xpubs + return desc, self.rpc.deriveaddresses(desc, [0, 1])[0], xpubs def _test_signtx(self, input_types, multisig_types, external, op_return: bool): # Import some keys to the watch only wallet and send coins to them @@ -520,7 +518,7 @@ def _test_signtx(self, input_types, multisig_types, external, op_return: bool): xpubs.update(sh_multi_xpubs) - sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "label": "shmulti"} + sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "range": [0, 10]} multi_result = self.wrpc.importdescriptors([sh_multi_import]) self.assertTrue(multi_result[0]['success']) @@ -533,8 +531,8 @@ def _test_signtx(self, input_types, multisig_types, external, op_return: bool): xpubs.update(sh_wsh_xpubs) xpubs.update(wsh_xpubs) - sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "label": "shwshmulti"} - wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "label": "wshmulti"} + sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "range": [0, 10]} + wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "range": [0, 10]} multi_result = self.wrpc.importdescriptors([sh_wsh_multi_import, wsh_multi_import]) self.assertTrue(multi_result[0]['success']) @@ -732,6 +730,12 @@ def _make_single_multisig(self, addrtype, sort, use_xpub): else: self.fail("Oops the test is broken") + if self.emulator.register_multisig: + desc_name = f"{addrtype}_{'sorted' if sort else 'unsorted'}_{'xpub' if use_xpub else 'single'}" + result = self.do_command(self.dev_args + ["registerdescriptor", desc_name, desc]) + self.assertNotIn("error", result) + self.assertIn("registration", result) + return addr, desc def test_display_address_multisig(self): @@ -784,3 +788,29 @@ def test_sign_msg(self): def test_bad_path(self): result = self.do_command(self.dev_args + ['signmessage', "Message signing test", 'f']) self.assertEqual(result['code'], -7) + +class TestRegisterDescriptor(DeviceTestCase): + def __init__(self, *args, returns_registration, sorted=True, **kwargs): + super().__init__(*args, **kwargs) + self.returns_registration = returns_registration + self.sorted = sorted + + def test_register_descriptor(self): + account_path = "m/48h/1h/0h/2h" + account_xpub = self.do_command(self.dev_args + ["getxpub", account_path])["xpub"] + multi = "sortedmulti" if self.sorted else "multi" + descriptor = f"wsh({multi}(1,[{self.emulator.fingerprint}{account_path[1:]}]{account_xpub}/<0;1>/*,[1a0f5425{account_path[1:]}]tpubDF23ETNjCC283QmYZtJp26GqHkSa6Yw6vPqp3UkMsPCvBzRC4dMQzE1U3WwKsFsx3apUkQA4JHQDSmcC3N1yhE2gF1aKJA1CiVtNyA9Rv4H/<0;1>/*))" # noqa: E702 + desc_name = "HWI_testing" + result = self.do_command(self.dev_args + ["registerdescriptor", desc_name, descriptor]) + + self.assertNotIn("error", result) + self.assertIn("registration", result) + reg_str = result["registration"] + reg = RegisteredDescriptor.deserialize(reg_str) + self.assertEqual(reg.name, desc_name) + self.assertEqual(reg.descriptor.to_string_no_checksum(), descriptor) + self.assertEqual(reg.device_type, self.emulator.type) + if self.returns_registration: + self.assertGreater(len(reg.registration), 0) + else: + self.assertEqual(len(reg.registration), 0) diff --git a/test/test_digitalbitbox.py b/test/test_digitalbitbox.py index 9fc41ed3f..aaecf3b43 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -41,9 +41,9 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_jade.py b/test/test_jade.py index f5fef76dd..5617a4e18 100755 --- a/test/test_jade.py +++ b/test/test_jade.py @@ -18,6 +18,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -51,9 +52,9 @@ def __init__(self, jade_qemu_emulator_path): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() @@ -249,6 +250,7 @@ def jade_test_suite(emulator, bitcoind, interface): suite.addTest(DeviceTestCase.parameterize(TestJadeGetMultisigAddresses, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestJadeSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_keepkey.py b/test/test_keepkey.py index ff9cf84c0..f2cf0296b 100755 --- a/test/test_keepkey.py +++ b/test/test_keepkey.py @@ -63,9 +63,9 @@ def __init__(self, path): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_ledger.py b/test/test_ledger.py index 00a0ceeb2..4887617bc 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -17,6 +17,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -45,10 +46,10 @@ def __init__(self, path, legacy=False): self.supports_taproot = not legacy # Legacy does not support Taproot self.strict_bip48 = True self.include_xpubs = True - self.supports_device_multiple_multisig = True self.supports_legacy = True # Bitcoin app 2.5.0 enforces standard Ledger paths for xpub derivation. self.supports_arbitrary_keypool_paths = legacy + self.register_multisig = not legacy def start(self): super().start() @@ -190,6 +191,8 @@ def ledger_test_suite(emulator, bitcoind, interface, legacy=False): suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + if not legacy: + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=True)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_trezor.py b/test/test_trezor.py index b61c1f00a..cae937af1 100755 --- a/test/test_trezor.py +++ b/test/test_trezor.py @@ -62,9 +62,9 @@ def __init__(self, path, model): self.supports_taproot = True self.strict_bip48 = True self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start()