diff --git a/hwilib/_cli.py b/hwilib/_cli.py index e0afa7dd2..32a0d750f 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -22,6 +22,7 @@ ) from .common import ( AddressType, + BIP388Policy, Chain, ) from .errors import ( @@ -88,7 +89,13 @@ def signmessage_handler(args: argparse.Namespace, client: HardwareWalletClient) return signmessage(client, message=args.message, path=args.path) def signtx_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, str]]: - return signtx(client, psbt=args.psbt) + policy = BIP388Policy( + name=args.policy_name, + descriptor_template=args.policy_desc, + keys_info=args.key, + hmac=args.hmac + ) + return signtx(client, psbt=args.psbt, bip388_policy=policy) def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return wipe_device(client) @@ -161,6 +168,11 @@ def get_parser() -> HWIArgumentParser: signtx_parser = subparsers.add_parser('signtx', help='Sign a PSBT') signtx_parser.add_argument('psbt', help='The Partially Signed Bitcoin Transaction to sign') + signtx_policy_group = signtx_parser.add_argument_group("BIP388 policy") + signtx_policy_group.add_argument('--policy-name', help='Registered policy name') + signtx_policy_group.add_argument('--policy-desc', help='Registered policy descriptor template') + signtx_policy_group.add_argument('--key', help='Registered policy key information', action='append') + signtx_policy_group.add_argument('--hmac', help='Registered policy hmac, obtained via register command') signtx_parser.set_defaults(func=signtx_handler) getxpub_parser = subparsers.add_parser('getxpub', help='Get an extended public key') diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..d8c69d8c6 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -52,6 +52,7 @@ from .devices import __all__ as all_devs from .common import ( AddressType, + BIP388Policy, Chain, ) from .hwwclient import HardwareWalletClient @@ -183,7 +184,11 @@ def getmasterxpub(client: HardwareWalletClient, addrtype: AddressType = AddressT """ return {"xpub": client.get_master_xpub(addrtype, account).to_string()} -def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str]]: +def signtx( + client: HardwareWalletClient, + psbt: str, + bip388_policy: Optional[BIP388Policy] +) -> Dict[str, Union[bool, str]]: """ Sign a Partially Signed Bitcoin Transaction (PSBT) with the client. @@ -195,7 +200,7 @@ def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str # Deserialize the transaction tx = PSBT() tx.deserialize(psbt) - result = client.sign_tx(tx).serialize() + result = client.sign_tx(tx, bip388_policy).serialize() return {"psbt": result, "signed": result != psbt} def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]: diff --git a/hwilib/common.py b/hwilib/common.py index 0c5c00606..862b155a6 100644 --- a/hwilib/common.py +++ b/hwilib/common.py @@ -3,12 +3,17 @@ **************************** """ +from dataclasses import dataclass + import hashlib from enum import Enum -from typing import Union - +from typing import ( + List, + Optional, + Union, +) class Chain(Enum): """ @@ -56,6 +61,15 @@ def argparse(s: str) -> Union['AddressType', str]: except KeyError: return s +@dataclass +class BIP388Policy: + """ + Serialization agnostic BIP388 policy. + """ + name: str + descriptor_template: str + keys_info: List[str] + hmac: Optional[str] = None def sha256(s: bytes) -> bytes: """ diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py index cc6b783b3..c908e5eb1 100644 --- a/hwilib/devices/bitbox02.py +++ b/hwilib/devices/bitbox02.py @@ -57,6 +57,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) @@ -563,7 +564,11 @@ def display_multisig_address( return address @bitbox02_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the BitBox02. diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index e64f3619c..4db1950d9 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -51,6 +51,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) from functools import wraps @@ -116,7 +117,11 @@ def get_master_fingerprint(self) -> bytes: return struct.pack(' PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Coldcard. @@ -132,7 +137,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # For multisigs, we may need to do multiple passes if we appear in an input multiple times passes = 1 - for psbt_in in tx.inputs: + for psbt_in in psbt.inputs: our_keys = 0 for key in psbt_in.hd_keypaths.keys(): keypath = psbt_in.hd_keypaths[key] @@ -143,8 +148,8 @@ def sign_tx(self, tx: PSBT) -> PSBT: for _ in range(passes): # Get psbt in hex and then make binary - tx.convert_to_v0() - fd = io.BytesIO(base64.b64decode(tx.serialize())) + psbt.convert_to_v0() + fd = io.BytesIO(base64.b64decode(psbt.serialize())) # learn size (portable way) sz = fd.seek(0, 2) @@ -190,10 +195,10 @@ def sign_tx(self, tx: PSBT) -> PSBT: result = self.device.download_file(result_len, result_sha, file_number=1) - tx = PSBT() - tx.deserialize(base64.b64encode(result).decode()) + psbt = PSBT() + psbt.deserialize(base64.b64encode(result).decode()) - return tx + return psbt @coldcard_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index 2733d0a2e..582f0b875 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -29,6 +29,7 @@ from ..common import ( AddressType, + BIP388Policy, Chain, hash256, ) @@ -387,17 +388,21 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @digitalbitbox_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: # Create a transaction with all scriptsigs blanked out - blank_tx = tx.get_unsigned_tx() + blank_tx = psbt.get_unsigned_tx() # Get the master key fingerprint master_fp = self.get_master_fingerprint() # create sighashes sighash_tuples = [] - for txin, psbt_in, i_num in zip(blank_tx.vin, tx.inputs, range(len(blank_tx.vin))): + for txin, psbt_in, i_num in zip(blank_tx.vin, psbt.inputs, range(len(blank_tx.vin))): sighash = b"" utxo = None if psbt_in.witness_utxo: @@ -493,7 +498,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # Return early if nothing to do if len(sighash_tuples) == 0: - return tx + return psbt for i in range(0, len(sighash_tuples), 15): tups = sighash_tuples[i:i + 15] @@ -533,9 +538,9 @@ def sign_tx(self, tx: PSBT) -> PSBT: # add sigs to tx for tup, sig in zip(tups, der_sigs): - tx.inputs[tup[2]].partial_sigs[tup[3]] = sig + psbt.inputs[tup[2]].partial_sigs[tup[3]] = sig - return tx + return psbt @digitalbitbox_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 2530ebb34..3cf7b5dcc 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -34,6 +34,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, sha256 ) @@ -370,16 +371,21 @@ def _split_at_last_hardened_element(path: Sequence[int]) -> Tuple[Sequence[int], # Sign tx PSBT - newer Jade firmware supports native PSBT signing, but old firmwares require # mapping to the legacy 'sign_tx' structures. @jade_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Blockstream Jade. """ + # Old firmware does not have native PSBT handling - use legacy method if self.PSBT_SUPPORTED_FW_VERSION > self.fw_version.finalize_version(): - return self.legacy_sign_tx(tx) + return self.legacy_sign_tx(psbt) # Firmware 0.1.47 (March 2023) and later support native PSBT signing - psbt_b64 = tx.serialize() + psbt_b64 = psbt.serialize() psbt_bytes = base64.b64decode(psbt_b64.strip()) # NOTE: sign_psbt() does not use AE signatures, so sticks with default (rfc6979) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index d3cf46325..3d7666780 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -32,6 +32,7 @@ from ..common import ( AddressType, Chain, + BIP388Policy, ) from .ledger_bitcoin.client import ( createClient, @@ -185,7 +186,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return ExtendedKey.deserialize(xpub_str) @ledger_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + bip388_policy: Optional[BIP388Policy] + ) -> PSBT: """ Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger. @@ -198,6 +203,8 @@ def sign_tx(self, tx: PSBT) -> PSBT: For application versions 2.1.x and above: - Only keys derived with standard BIP 44, 49, 84, and 86 derivation paths are supported for single signature addresses. + + BIP388: for basic descriptors this is optional, but if provided name must be empty """ master_fp = self.get_master_fingerprint() @@ -206,20 +213,20 @@ def legacy_sign_tx() -> PSBT: if not isinstance(client, LegacyClient): client = LegacyClient(self.transport_client, self.chain) wallet = WalletPolicy("", "wpkh(@0/**)", [""]) - legacy_input_sigs = client.sign_psbt(tx, wallet, None) + legacy_input_sigs = client.sign_psbt(psbt, wallet, None) for idx, pubkey, sig in legacy_input_sigs: - psbt_in = tx.inputs[idx] + psbt_in = psbt.inputs[idx] psbt_in.partial_sigs[pubkey] = sig - return tx + return psbt if isinstance(self.client, LegacyClient): return legacy_sign_tx() # Make a deepcopy of this psbt. We will need to modify it to get signing to work, # which will affect the caller's detection for whether signing occured. - psbt2 = copy.deepcopy(tx) - if tx.version != 2: + psbt2 = copy.deepcopy(psbt) + if psbt.version != 2: psbt2.convert_to_v2() # Figure out which wallets are signing @@ -264,6 +271,25 @@ def legacy_sign_tx() -> PSBT: else: continue + if bip388_policy is not None: + policy = WalletPolicy( + name=bip388_policy.name, + descriptor_template=bip388_policy.descriptor_template, + keys_info=bip388_policy.keys_info + ) + if policy.id not in wallets: + if bip388_policy.hmac is None: + raise BadArgumentError("Missing --hmac") + wallets[policy.id] = ( + signing_priority[script_addrtype], + script_addrtype, + policy, + bytes.fromhex(bip388_policy.hmac), + ) + continue + + # No BIP388 policy provided, construct on the fly + # Check if P2WSH if is_p2wsh(scriptcode): if len(psbt_in.witness_script) == 0: @@ -369,13 +395,13 @@ def process_origin(origin: KeyOriginInfo) -> None: psbt_in.partial_sigs[pubkey] = sig # Extract the sigs from psbt2 and put them into tx - for sig_in, psbt_in in zip(psbt2.inputs, tx.inputs): + for sig_in, psbt_in in zip(psbt2.inputs, psbt.inputs): psbt_in.partial_sigs.update(sig_in.partial_sigs) psbt_in.tap_script_sigs.update(sig_in.tap_script_sigs) if len(sig_in.tap_key_sig) != 0 and len(psbt_in.tap_key_sig) == 0: psbt_in.tap_key_sig = sig_in.tap_key_sig - return tx + return psbt @ledger_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index bd5a79733..b7510474b 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -77,6 +77,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, hash256, ) @@ -355,7 +356,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @trezor_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Trezor. There are some limitations to what transactions can be signed. @@ -378,7 +383,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # Prepare inputs inputs = [] to_ignore = [] # Note down which inputs whose signatures we're going to ignore - for input_num, psbt_in in builtins.enumerate(tx.inputs): + for input_num, psbt_in in builtins.enumerate(psbt.inputs): assert psbt_in.prev_txid is not None assert psbt_in.prev_out is not None assert psbt_in.sequence is not None @@ -443,7 +448,7 @@ def ignore_input() -> None: to_ignore.append(input_num) # Check for multisig - is_ms, multisig = parse_multisig(scriptcode, tx.xpub, psbt_in) + is_ms, multisig = parse_multisig(scriptcode, psbt.xpub, psbt_in) if is_ms: # Add to txinputtype txinputtype.multisig = multisig @@ -529,7 +534,7 @@ def ignore_input() -> None: # prepare outputs outputs = [] - for psbt_out in tx.outputs: + for psbt_out in psbt.outputs: out = psbt_out.get_txout() txoutput = messages.TxOutputType(amount=out.nValue) txoutput.script_type = messages.OutputScriptType.PAYTOADDRESS @@ -578,7 +583,7 @@ def ignore_input() -> None: if psbt_out.witness_script or psbt_out.redeem_script: is_ms, multisig = parse_multisig( psbt_out.witness_script or psbt_out.redeem_script, - tx.xpub, psbt_out) + psbt.xpub, psbt_out) if is_ms: txoutput.multisig = multisig if not wit: @@ -589,7 +594,7 @@ def ignore_input() -> None: # Prepare prev txs prevtxs = {} - for psbt_in in tx.inputs: + for psbt_in in psbt.inputs: if psbt_in.non_witness_utxo: prev = psbt_in.non_witness_utxo @@ -618,20 +623,20 @@ def ignore_input() -> None: prevtxs[ser_uint256(psbt_in.non_witness_utxo.sha256)[::-1]] = t # Sign the transaction - assert tx.tx_version is not None + assert psbt.tx_version is not None signed_tx = btc.sign_tx( client=self.client, coin_name=self.coin_name, inputs=inputs, outputs=outputs, prev_txes=prevtxs, - version=tx.tx_version, - lock_time=tx.compute_lock_time(), + version=psbt.tx_version, + lock_time=psbt.compute_lock_time(), serialize=False, ) # Each input has one signature - for input_num, (psbt_in, sig) in py_enumerate(list(zip(tx.inputs, signed_tx[0]))): + for input_num, (psbt_in, sig) in py_enumerate(list(zip(psbt.inputs, signed_tx[0]))): if input_num in to_ignore: continue for pubkey in psbt_in.hd_keypaths.keys(): @@ -646,7 +651,7 @@ def ignore_input() -> None: p += 1 - return tx + return psbt @trezor_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index 565afcf44..225c0b792 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -17,7 +17,11 @@ get_bip44_chain, ) from .psbt import PSBT -from .common import AddressType, Chain +from .common import ( + AddressType, + Chain, + BIP388Policy, +) class HardwareWalletClient(object): @@ -78,7 +82,11 @@ def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + bip388_policy: Optional[BIP388Policy] + ) -> PSBT: """ Sign a partially signed bitcoin transaction (PSBT).