Merge bitcoin-core/HWI#842: Add `registerdescriptors` command for registering a descriptor with a device
What changed, and why it matters
This commit adds a new command called `registerdescriptor` to the HWI tool, which lets users register Bitcoin output descriptors with supported hardware wallets (Ledger, BitBox02, Jade, Coldcard). It also rewrites how descriptors are parsed and stored internally to support newer multi-path descriptor formats and to convert descriptors into BIP 388 wallet policies where needed. The change is a feature addition, not a bug fix, and the commit message and code do not describe any security vulnerability. The main security-relevant aspect is that the tool now passes user-supplied descriptors directly to the device without extra validation, relying on each device's own checks.
Treat this as a routine feature merge, not a security patch. Reviewers should focus on whether the new descriptor parsing and BIP 388 conversion correctly preserve semantics for all descriptor types, whether the `registerdescriptor` command properly handles malformed or malicious descriptors, and whether the `RegisteredDescriptor` serialization format is robust. Because the commit explicitly states that no validation is performed, downstream users should ensure they only register descriptors they trust and that their hardware wallet firmware performs adequate checks.
Security signals we found
New command registers user-supplied descriptors with hardware wallets
HWI explicitly does not validate descriptors before passing them to the device; device errors are propagated
Descriptor parser changed from string-based derivation paths to structured list-of-lists, affecting all descriptor handling
sortedmulti no longer sorts pubkeys at construction time
BIP 388 policy conversion added for Ledger, BitBox02, and Jade
RegisteredDescriptor serialization uses a custom binary format with base64 encoding and version check
No CVE, advisory, or vendor security statement present in the supplied materials
Evidence from the diff
The merge commit introduces register_descriptor() across the CLI, commands layer, base HardwareWalletClient, and per-device implementations. It adds RegisteredDescriptor serialization, BIP 388 policy template generation, and multipath (BIP 389) parsing. Devices that do not support descriptor registration (Trezor, KeepKey, Digital BitBox) raise UnavailableActionError. Ledger returns an HMAC; BitBox02, Jade, and Coldcard return empty registrations. The descriptor parser is refactored so deriv_path is now a list of lists of integers, PubkeyProvider tracks expr_index and ranged, and sortedmulti no longer sorts pubkeys at parse time. No explicit security bug is fixed or disclosed in the commit materials.
Changed components
hwilib/_cli.pyhwilib/commands.pyhwilib/descriptor.pyhwilib/key.pyhwilib/hwwclient.pyhwilib/devices/bitbox02.pyhwilib/devices/coldcard.pyhwilib/devices/jade.pyhwilib/devices/ledger.pyhwilib/devices/trezor.pyhwilib/devices/keepkey.pyhwilib/devices/digitalbitbox.pyhwilib/errors.pyInspect captured patch +709 / −123
### 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/')
### 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()}
### 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,20 +597,21 @@ 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(",")
next_expr = ""
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)
### 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 /<change>/<address>.
# 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"")
### 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 = []
### 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 = []
### 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 = []
### 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 = []
### 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 = []
### 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 = []
### 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,
### 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")
### 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:
### 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 ]
### test/test_bitbox02.py
@@ -18,6 +18,7 @@
TestDisplayAddress,
TestGetKeypool,
TestGetDescriptors,
+ TestRegisterDescriptor,
TestSignTx,
)
@@ -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()
### test/test_coldcard.py
@@ -19,6 +19,7 @@
TestDisplayAddress,
TestGetKeypool,
TestGetDescriptors,
+ TestRegisterDescriptor,
TestSignMessage,
TestSignTx,
)
@@ -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()
### 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__":
### 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
@@ -784,3 +784,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)
### test/test_jade.py
@@ -18,6 +18,7 @@
TestDisplayAddress,
TestGetKeypool,
TestGetDescriptors,
+ TestRegisterDescriptor,
TestSignMessage,
TestSignTx,
)
@@ -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()
### test/test_ledger.py
@@ -17,6 +17,7 @@
TestDisplayAddress,
TestGetKeypool,
TestGetDescriptors,
+ TestRegisterDescriptor,
TestSignMessage,
TestSignTx,
)
@@ -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()Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.