signtx: add BIP388 policy support
What changed, and why it matters
This commit adds a new command-line option and API parameter for BIP388 registered descriptor policies to the transaction-signing flow. It does not implement actual signing support in any hardware wallet driver; every device implementation explicitly rejects the new option with an error message. There is no security vulnerability here—it's a feature plumbing change that prepares the codebase for future per-device support.
No security action required. Treat as a normal feature/refactoring commit. Future commits that add actual BIP388 signing support in individual device drivers should be reviewed for correct policy validation and user confirmation behavior.
Security signals we found
New API surface added for BIP388 policy registration
All device implementations explicitly reject BIP388 policy signing with UnavailableActionError
No existing signing path is modified; default behavior unchanged
No input validation, parsing, or deserialization of registrations beyond existing RegisteredDescriptor.deserialize
Evidence from the diff
The change threads an optional registrations argument through the CLI, commands.signtx, the base HardwareWalletClient.sign_tx, and every concrete device driver. commands.signtx deserializes each registration string into a RegisteredDescriptor and passes the set to client.sign_tx. Each device driver (BitBox02, Coldcard, Digital Bitbox, Jade, KeepKey, Ledger, Trezor) currently raises UnavailableActionError if any registered descriptors are supplied. No signing logic is altered for the existing registered_descriptors=None path.
Changed components
hwilib/_cli.pyhwilib/commands.pyhwilib/hwwclient.pyhwilib/devices/bitbox02.pyhwilib/devices/coldcard.pyhwilib/devices/digitalbitbox.pyhwilib/devices/jade.pyhwilib/devices/keepkey.pyhwilib/devices/ledger.pyhwilib/devices/trezor.pyInspect captured patch +85 / −11
### hwilib/_cli.py
@@ -97,7 +97,7 @@ 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)
+ return signtx(client, psbt=args.psbt, registrations=args.registrations)
def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
return wipe_device(client)
@@ -173,6 +173,12 @@ 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_parser.add_argument(
+ '--registration',
+ dest='registrations',
+ action='append',
+ help='Registration returned by the registerdescriptor command; may be specified multiple times',
+ )
signtx_parser.set_defaults(func=signtx_handler)
getxpub_parser = subparsers.add_parser('getxpub', help='Get an extended public key')
### hwilib/commands.py
@@ -183,19 +183,28 @@ 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,
+ registrations: Optional[List[str]] = None,
+) -> Dict[str, Union[bool, str]]:
"""
Sign a Partially Signed Bitcoin Transaction (PSBT) with the client.
:param client: The client to interact with
:param psbt: The PSBT to sign
+ :param registrations: Serialized registered descriptors for BIP388 policy signing
:return: A dictionary containing the processed PSBT serialized in Base64.
Returned as ``{"psbt": <base64 psbt string>}``.
"""
# Deserialize the transaction
tx = PSBT()
tx.deserialize(psbt)
- result = client.sign_tx(tx).serialize()
+ registered_descriptors = {
+ RegisteredDescriptor.deserialize(registration)
+ for registration in registrations or []
+ }
+ result = client.sign_tx(tx, registered_descriptors).serialize()
return {"psbt": result, "signed": result != psbt}
def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]:
### hwilib/devices/bitbox02.py
@@ -14,6 +14,7 @@
Tuple,
List,
Sequence,
+ Set,
TypeVar,
)
import base64
@@ -594,7 +595,11 @@ def display_multisig_address(
return address
@bitbox02_exception
- def sign_tx(self, psbt: PSBT) -> PSBT:
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a transaction with the BitBox02.
@@ -604,6 +609,9 @@ def sign_tx(self, psbt: PSBT) -> PSBT:
Transactions with legacy inputs are not supported.
"""
+ if registered_descriptors:
+ raise UnavailableActionError("The BitBox02 does not support BIP388 policy signing")
+
def find_our_key(
keypaths: Dict[bytes, KeyOriginInfo]
) -> Tuple[Optional[bytes], Optional[Sequence[int]]]:
### hwilib/devices/coldcard.py
@@ -75,6 +75,7 @@
Any,
Callable,
Optional,
+ Set,
)
CC_SIMULATOR_SOCK = '/tmp/ckcc-simulator.sock'
@@ -155,14 +156,20 @@ def get_master_fingerprint(self) -> bytes:
return struct.pack('<I', self.device.master_fingerprint)
@coldcard_exception
- def sign_tx(self, psbt: PSBT) -> PSBT:
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a transaction with the Coldcard.
- The Coldcard firmware only supports signing single key and multisig transactions. It cannot sign arbitrary scripts.
- Multisigs need to be registered on the device before a transaction spending that multisig will be signed by the device.
- Multisigs must use BIP 67. This can be accomplished in Bitcoin Core using the `sortedmulti()` descriptor, available in Bitcoin Core 0.20.
"""
+ if registered_descriptors:
+ raise UnavailableActionError("The Coldcard does not support BIP388 policy signing")
self.device.check_mitm()
# Get this devices master key fingerprint
### hwilib/devices/digitalbitbox.py
@@ -23,6 +23,7 @@
Dict,
List,
Optional,
+ Set,
Tuple,
Union,
)
@@ -391,8 +392,14 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey:
return xpub
@digitalbitbox_exception
- def sign_tx(self, psbt: PSBT) -> PSBT:
-
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
+
+ if registered_descriptors:
+ raise UnavailableActionError("The Digital Bitbox does not support BIP388 policy signing")
# Create a transaction with all scriptsigs blanked out
blank_tx = psbt.get_unsigned_tx()
### hwilib/devices/jade.py
@@ -17,6 +17,7 @@
List,
Optional,
Sequence,
+ Set,
Tuple,
Union
)
@@ -374,10 +375,16 @@ 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, psbt: PSBT) -> PSBT:
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a transaction with the Blockstream Jade.
"""
+ if registered_descriptors:
+ raise UnavailableActionError("The Jade does not support BIP388 policy signing")
# 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(psbt)
### hwilib/devices/keepkey.py
@@ -15,6 +15,7 @@
handle_errors,
UnavailableActionError,
)
+from ..psbt import PSBT
from .trezorlib import protobuf
from .trezorlib.transport import (
hid,
@@ -35,6 +36,7 @@
Dict,
List,
Optional,
+ Set,
)
py_enumerate = enumerate # Need to use the enumerate built-in but there's another function already named that
@@ -176,6 +178,15 @@ def can_sign_taproot(self) -> bool:
"""
return False
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
+ if registered_descriptors:
+ raise UnavailableActionError("The KeepKey does not support BIP388 policy signing")
+ return super().sign_tx(psbt, registered_descriptors)
+
def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor:
"""
The KeepKey does not support registering descriptors
### hwilib/devices/ledger.py
@@ -10,6 +10,7 @@
Dict,
List,
Optional,
+ Set,
Tuple,
Union,
)
@@ -188,7 +189,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey:
return ExtendedKey.deserialize(xpub_str)
@ledger_exception
- def sign_tx(self, psbt: PSBT) -> PSBT:
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger.
@@ -202,6 +207,8 @@ def sign_tx(self, psbt: PSBT) -> PSBT:
- Only keys derived with standard BIP 44, 49, 84, and 86 derivation paths are supported for single signature addresses.
"""
+ if registered_descriptors:
+ raise UnavailableActionError("The Ledger does not support BIP388 policy signing")
master_fp = self.get_master_fingerprint()
def legacy_sign_tx() -> PSBT:
### hwilib/devices/trezor.py
@@ -359,7 +359,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey:
return xpub
@trezor_exception
- def sign_tx(self, psbt: PSBT) -> PSBT:
+ def sign_tx(
+ self,
+ psbt: PSBT,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a transaction with the Trezor. There are some limitations to what transactions can be signed.
@@ -368,6 +372,8 @@ def sign_tx(self, psbt: PSBT) -> PSBT:
- Send-to-self transactions will result in no prompt for outputs as all outputs will be detected as change.
- Transactions containing Taproot inputs cannot have external inputs.
"""
+ if registered_descriptors:
+ raise UnavailableActionError("The Trezor does not support BIP388 policy signing")
self._check_unlocked()
# Get this devices master key fingerprint
### hwilib/hwwclient.py
@@ -8,6 +8,7 @@
from typing import (
Dict,
Optional,
+ Set,
Union,
)
from .descriptor import (
@@ -82,11 +83,16 @@ 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,
+ registered_descriptors: Optional[Set[RegisteredDescriptor]] = None,
+ ) -> PSBT:
"""
Sign a partially signed bitcoin transaction (PSBT).
:param psbt: The PSBT to sign
+ :param registered_descriptors: The registered BIP388 descriptor policies to sign with
:return: The PSBT after being processed by the hardware wallet
"""
raise NotImplementedError("The HardwareWalletClient base class "Why this scored 17/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.