diff --git a/.github/actions/build-bitcoind/action.yml b/.github/actions/build-bitcoind/action.yml index e4befd42e..d04934baa 100644 --- a/.github/actions/build-bitcoind/action.yml +++ b/.github/actions/build-bitcoind/action.yml @@ -23,7 +23,7 @@ runs: ccache --zero-stats cd test; ./setup_environment.sh --bitcoind; cd .. ccache --show-stats --verbose - tar -czf bitcoind.tar.gz test/work/bitcoin/build/bin/bitcoind + tar -czf bitcoind.tar.gz test/work/bitcoin/build/bin/bitcoind test/work/bitcoin/build/bin/bitcoin-cli - uses: actions/cache/save@v4 if: github.event_name != 'pull_request' && steps.ccache-cache.outputs.cache-hit != 'true' diff --git a/.github/actions/test-device/action.yml b/.github/actions/test-device/action.yml index 2fc72beda..c29378ebc 100644 --- a/.github/actions/test-device/action.yml +++ b/.github/actions/test-device/action.yml @@ -25,6 +25,19 @@ runs: run: | cd test; poetry run ./run_tests.py --${{ matrix.device }} --interface=${{ matrix.test.interface }} --device-only; cd .. + - name: Run Ledger MuSig2 scenario + if: matrix.device == 'ledger' && matrix.test.interface + shell: bash + env: + BITCOIND: ${{ github.workspace }}/test/work/bitcoin/build/bin/bitcoind + BITCOIN_CLI: ${{ github.workspace }}/test/work/bitcoin/build/bin/bitcoin-cli + HWI_REPO: ${{ github.workspace }} + LEDGER_APP_ELF: ${{ github.workspace }}/test/work/speculos/apps/btc-test.elf + PYTHON: poetry run python + SPECULOS: poetry run speculos + run: | + test/run_ledger_musig2_scenario.sh + - if: failure() shell: bash run: | diff --git a/.github/workflows/ledger-app-builder.yml b/.github/workflows/ledger-app-builder.yml index d873a2411..e97f883b6 100644 --- a/.github/workflows/ledger-app-builder.yml +++ b/.github/workflows/ledger-app-builder.yml @@ -16,10 +16,10 @@ jobs: steps: - run: | # Pin to v2.4.1 - last version that worked with HWI CI (PR #795 merged Sept 2025) - git clone --branch 2.4.1 --depth 1 https://github.com/LedgerHQ/app-bitcoin-new.git - cd app-bitcoin-new + git clone --branch 2.4.1 --depth 1 https://github.com/LedgerHQ/app-bitcoin.git + cd app-bitcoin make DEBUG=1 BOLOS_SDK=$NANOX_SDK - uses: actions/upload-artifact@v4 with: name: ledger_app - path: app-bitcoin-new/bin/app.elf + path: app-bitcoin/bin/app.elf diff --git a/.github/workflows/ledger-legacy-app-builder.yml b/.github/workflows/ledger-legacy-app-builder.yml index aac5afb1a..d9c6179bc 100644 --- a/.github/workflows/ledger-legacy-app-builder.yml +++ b/.github/workflows/ledger-legacy-app-builder.yml @@ -16,10 +16,10 @@ jobs: steps: - run: | # Pin to legacy-1.6.6 HEAD commit for reproducibility - git clone --depth 1 https://github.com/LedgerHQ/app-bitcoin.git -b legacy-1.6.6 - cd app-bitcoin + git clone --depth 1 https://github.com/LedgerHQ/app-bitcoin-legacy.git -b legacy-1.6.6 + cd app-bitcoin-legacy make DEBUG=1 BOLOS_SDK=$NANOSP_SDK - uses: actions/upload-artifact@v4 with: name: ledger_app_legacy - path: app-bitcoin/bin/app.elf + path: app-bitcoin-legacy/bin/app.elf diff --git a/README.md b/README.md index c80374b18..ec1f8fab2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bitcoin Hardware Wallet Interface -[![Build Status](https://api.cirrus-ci.com/github/bitcoin-core/HWI.svg)](https://cirrus-ci.com/github/bitcoin-core/HWI) +[![CI](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml/badge.svg)](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml) [![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest) The Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets. diff --git a/ci/cirrus.Dockerfile b/ci/cirrus.Dockerfile deleted file mode 100644 index 07c201c6e..000000000 --- a/ci/cirrus.Dockerfile +++ /dev/null @@ -1,99 +0,0 @@ -# Cache break (modify this line to break cirrus' dockerfile build cache) 1 - -FROM python:3.9 - -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update -RUN apt-get install -y \ - autotools-dev \ - automake \ - bsdmainutils \ - build-essential \ - ccache \ - clang \ - cmake \ - curl \ - cython3 \ - gcc-arm-none-eabi \ - gcc-arm-linux-gnueabihf \ - git \ - libboost-system-dev \ - libboost-filesystem-dev \ - libboost-chrono-dev \ - libboost-test-dev \ - libboost-thread-dev \ - libc6-dev-armhf-cross \ - libdb-dev \ - libdb++-dev \ - libevent-dev \ - libgcrypt20-dev \ - libnewlib-arm-none-eabi \ - libpcsclite-dev \ - libsdl2-dev \ - libsdl2-image-dev \ - libssl-dev \ - libslirp-dev \ - libtool \ - libudev-dev \ - libusb-1.0-0-dev \ - ninja-build \ - pkg-config \ - qemu-user-static \ - swig - -RUN pip install poetry flake8 -RUN wget https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init -RUN chmod +x rustup-init && ./rustup-init -y -ENV PATH="/root/.cargo/bin:$PATH" -RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip -RUN unzip protoc-22.0-linux-x86_64.zip -d /usr/local -RUN protoc --version - -#################### -# Local build/test steps -# ----------------- -# To install all simulators/tests locally, uncomment the block below, -# then build the docker image and interactively run the tests -# as needed. -# e.g., -# docker build -f ci/cirrus.Dockerfile -t hwi_test . -# docker run -it --entrypoint /bin/bash hwi_test -# cd test; poetry run ./run_tests.py --ledger --coldcard --interface=cli --device-only -# For BitBox02: -# docker build -f ci/cirrus.Dockerfile -t hwi_test . -# ./ci/build_bitbox02.sh -# docker run -it -v bitbox02_volume:/test/work/bitbox02-firmware --name hwi --entrypoint /bin/bash hwi_test -# cd test; poetry run ./run_tests.py --bitbox02 --interface=cli --device-only -#################### - -#################### -#ENV EMAIL=email -#COPY pyproject.toml pyproject.toml -#RUN poetry run pip install construct pyelftools mnemonic jsonschema -# -## Set up environments first to take advantage of layer caching -#RUN mkdir test -#COPY test/setup_environment.sh test/setup_environment.sh -#COPY test/data/coldcard-multisig.patch test/data/coldcard-multisig.patch -## One by one to allow for intermediate caching of successful builds -#RUN cd test; ./setup_environment.sh --trezor-1 -#RUN cd test; ./setup_environment.sh --trezor-t -#RUN cd test; ./setup_environment.sh --coldcard -#RUN cd test; ./setup_environment.sh --bitbox01 -#RUN cd test; ./setup_environment.sh --ledger -#RUN cd test; ./setup_environment.sh --keepkey -#RUN cd test; ./setup_environment.sh --jade -#RUN cd test; ./setup_environment.sh --bitcoind -# -## Once everything has been built, put rest of files in place -## which have higher turn-over. -#COPY test/ test/ -#COPY hwi.py hwi-qt.py README.md / -#COPY hwilib/ /hwilib/ -#RUN poetry install -# -#################### - -ENV LC_ALL=C.UTF-8 -ENV LANG=C.UTF-8 -ENV LANGUAGE=C.UTF-8 diff --git a/ci/py310.Dockerfile b/ci/py310.Dockerfile index 299b44333..20106aa24 100644 --- a/ci/py310.Dockerfile +++ b/ci/py310.Dockerfile @@ -1,4 +1,4 @@ -# Cache break (modify this line to break cirrus' dockerfile build cache) 1 +# Cache break (modify this line to break the dockerfile build cache) 1 FROM python:3.10 diff --git a/ci/py311.Dockerfile b/ci/py311.Dockerfile index ab80e889a..cf97b42cf 100644 --- a/ci/py311.Dockerfile +++ b/ci/py311.Dockerfile @@ -1,4 +1,4 @@ -# Cache break (modify this line to break cirrus' dockerfile build cache) +# Cache break (modify this line to break the dockerfile build cache) FROM python:3.11 diff --git a/ci/py37.Dockerfile b/ci/py37.Dockerfile deleted file mode 100644 index fc8b67781..000000000 --- a/ci/py37.Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Cache break (modify this line to break cirrus' dockerfile build cache) 1 - -FROM python:3.7 - -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update -RUN apt-get install -y \ - cython3 \ - git \ - libpcsclite-dev \ - libsdl2-dev \ - libsdl2-image-dev \ - libslirp-dev \ - libudev-dev \ - libusb-1.0-0-dev \ - qemu-user-static \ - swig - -RUN pip install poetry flake8 - -ENV LC_ALL=C.UTF-8 -ENV LANG=C.UTF-8 -ENV LANGUAGE=C.UTF-8 diff --git a/docs/examples/index.rst b/docs/examples/index.rst index f95cedb8d..e2a611a2a 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -6,4 +6,5 @@ Example Usage bitcoin-core-usage examples + musig2-ledger walkthrough/walkthrough diff --git a/docs/examples/musig2-ledger.rst b/docs/examples/musig2-ledger.rst new file mode 100644 index 000000000..faf3ffdfa --- /dev/null +++ b/docs/examples/musig2-ledger.rst @@ -0,0 +1,100 @@ +MuSig2 With Bitcoin Core And Ledger +=================================== + +This walkthrough describes the HWI side of a 2-of-2 MuSig2 wallet where +cosigner A is a Ledger and cosigner B is a hot key in the same Bitcoin Core +wallet. It assumes a Bitcoin Core build with BIP388 policy support and +``tr(musig(...))`` descriptors. + +HWI currently supports this flow for Ledger only. + +Get The Ledger Key +------------------ + +Use BIP87 account keys for MuSig2. For test networks the account path is +``m/87h/1h/0h``: + +.. code-block:: bash + + ./hwi.py --chain test --fingerprint "$FP_A" getxpub "m/87h/1h/0h" + +Build the key expression with origin information: + +.. code-block:: bash + + COSIGNER_A_KEY="[${FP_A}/87h/1h/0h]tpub..." + +Create the Core wallet with ``external_signer=true``, +``disable_private_keys=false``, and ``blank=true``. Add cosigner B's hot HD key +to that wallet, derive its BIP87 account xpub, and build a descriptor like: + +.. code-block:: bash + + DESC_NO_CKSUM="tr(musig(${COSIGNER_A_KEY},${COSIGNER_B_KEY})/<0;1>/*)" + +Register The Policy +------------------- + +Bitcoin Core's ``registerpolicy`` RPC calls HWI's ``register`` command with the +BIP388 policy name, descriptor template, and one key per ``@N`` placeholder: + +.. code-block:: bash + + ./hwi.py --chain test --fingerprint "$FP_A" register \ + --name MuSigTest \ + --desc "tr(musig(@0,@1)/**)" \ + --key "$COSIGNER_A_KEY" \ + --key "$COSIGNER_B_KEY" + +The result is a deterministic policy HMAC for the device seed and policy: + +.. code-block:: json + + {"hmac":"85d68dad...30949f9c"} + +Display A Registered Address +---------------------------- + +For registered policies, Bitcoin Core's ``walletdisplayaddress`` invokes HWI's +policy display mode instead of passing a single-address descriptor: + +.. code-block:: bash + + ./hwi.py --chain test --fingerprint "$FP_A" displayaddress \ + --policy-name MuSigTest \ + --policy-desc "tr(musig(@0,@1)/**)" \ + --key "$COSIGNER_A_KEY" \ + --key "$COSIGNER_B_KEY" \ + --hmac "$HMAC" \ + --index 0 + +Use ``--change`` to display the change branch for the same index. HWI returns +the address reported by the device: + +.. code-block:: json + + { + "address": "tb1p...", + "index": 0, + "change": false + } + +Sign +---- + +Core invokes HWI's ``signtx`` with the same policy arguments and HMAC: + +.. code-block:: bash + + ./hwi.py --chain test --fingerprint "$FP_A" signtx "$PSBT" \ + --policy-name MuSigTest \ + --policy-desc "tr(musig(@0,@1)/**)" \ + --key "$COSIGNER_A_KEY" \ + --key "$COSIGNER_B_KEY" \ + --hmac "$HMAC" + +MuSig2 signing has two rounds. When the coordinating wallet has another +cosigner locally, Bitcoin Core can run both rounds inside one ``send`` call: +first HWI yields the Ledger's public nonce, then Core adds the hot cosigner's +nonce and partial signature, and finally HWI yields the Ledger's partial +signature. The returned PSBT is then ready for finalization by Core. diff --git a/hwilib/_cli.py b/hwilib/_cli.py index e0afa7dd2..b7b765a37 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -12,6 +12,7 @@ getdescriptors, prompt_pin, toggle_passphrase, + register, restore_device, send_pin, setup_device, @@ -22,6 +23,7 @@ ) from .common import ( AddressType, + BIP388Policy, Chain, ) from .errors import ( @@ -56,8 +58,20 @@ def backup_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return backup_device(client, label=args.label, backup_passphrase=args.backup_passphrase) -def displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: - return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type) +def displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, int, str]]: + policy = None + if args.policy_name is not None or args.policy_desc is not None or args.key is not None or args.hmac is not None or args.index is not None or args.change: + policy = BIP388Policy( + name=args.policy_name or "", + descriptor_template=args.policy_desc, + keys_info=args.key, + hmac=args.hmac, + ) + return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type, bip388_policy=policy, index=args.index, change=args.change) + +def register_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + policy = BIP388Policy(name=args.name, descriptor_template=args.desc, keys_info=args.key) + return register(client, bip388_policy=policy) def enumerate_handler(args: argparse.Namespace) -> List[Dict[str, Any]]: return enumerate(password=args.password, expert=args.expert, chain=args.chain, allow_emulators=args.allow_emulators) @@ -88,7 +102,15 @@ 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 = None + if args.policy_name is not None or args.policy_desc is not None: + 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 +183,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') @@ -194,9 +221,22 @@ def get_parser() -> HWIArgumentParser: group = displayaddr_parser.add_mutually_exclusive_group(required=True) group.add_argument('--desc', help='Output Descriptor. E.g. wpkh([00000000/84h/0h/0h]xpub.../0/0), where 00000000 must match --fingerprint and xpub can be obtained with getxpub. See doc/descriptors.md in Bitcoin Core') group.add_argument('--path', help='The BIP 32 derivation path of the key embedded in the address, default follows BIP43 convention, e.g. ``m/84h/0h/0h/1/*``') + group.add_argument('--policy-name', help='Registered BIP388 policy name') displayaddr_parser.add_argument("--addr-type", help="The address type to display", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore + displayaddr_policy_group = displayaddr_parser.add_argument_group("BIP388 policy") + displayaddr_policy_group.add_argument('--policy-desc', help='Registered policy descriptor template') + displayaddr_policy_group.add_argument('--key', help='Registered policy key information', action='append') + displayaddr_policy_group.add_argument('--index', help='Address index to display from the policy', type=int) + displayaddr_policy_group.add_argument('--hmac', help='Registered policy hmac, obtained via register command') + displayaddr_policy_group.add_argument('--change', help='Display the change address for the given index', action='store_true') displayaddr_parser.set_defaults(func=displayaddress_handler) + register_parser = subparsers.add_parser('register', help='Register a BIP388 wallet policy') + register_parser.add_argument('--name', help='Name for the policy') + register_parser.add_argument('--desc', help='Descriptor template, e.g. tr(musig(@0,@1)') + register_parser.add_argument('--key', help='Key information, e.g. [00000000/84h/0h/0h]xpub...', action='append') + register_parser.set_defaults(func=register_handler) + setupdev_parser = subparsers.add_parser('setup', help='Setup a device. Passphrase protection uses the password given by -p. Requires interactive mode') setupdev_parser.add_argument('--label', '-l', help='The name to give to the device', default='') setupdev_parser.add_argument('--backup_passphrase', '-b', help='The passphrase to use for the backup, if applicable', default='') diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..afa3dd546 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -21,7 +21,6 @@ import importlib import logging import platform - from ._base58 import xpub_to_pub_hex, xpub_to_xonly_pub_hex from .key import ( get_bip44_purpose, @@ -52,6 +51,7 @@ from .devices import __all__ as all_devs from .common import ( AddressType, + BIP388Policy, Chain, ) from .hwwclient import HardwareWalletClient @@ -183,7 +183,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] = None +) -> Dict[str, Union[bool, str]]: """ Sign a Partially Signed Bitcoin Transaction (PSBT) with the client. @@ -195,7 +199,12 @@ 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() + if bip388_policy is None: + # Preserve compatibility with external HardwareWalletClient subclasses + # that implement the pre-policy sign_tx(psbt) method shape. + result = client.sign_tx(tx).serialize() + else: + result = client.sign_tx(tx, bip388_policy=bip388_policy).serialize() return {"psbt": result, "signed": result != psbt} def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]: @@ -441,8 +450,11 @@ def displayaddress( client: HardwareWalletClient, path: Optional[str] = None, desc: Optional[str] = None, - addr_type: AddressType = AddressType.WIT -) -> Dict[str, str]: + addr_type: AddressType = AddressType.WIT, + bip388_policy: Optional[BIP388Policy] = None, + index: Optional[int] = None, + change: bool = False, +) -> Dict[str, Union[bool, int, str]]: """ Display an address on the device for client. The address can be specified by the path with additional parameters, or by a descriptor. @@ -451,10 +463,33 @@ def displayaddress( :param path: The path of the address to display. Mutually exclusive with ``desc`` :param desc: The descriptor to display the address for. Mutually exclusive with ``path`` :param addr_type: The address type to return. Only works with ``path`` + :param bip388_policy: Registered BIP388 policy to use. Mutually exclusive with ``path`` and ``desc`` + :param index: Address index to display for BIP388 policy mode + :param change: Whether to display the BIP388 policy change address :return: A dictionary containing the address displayed. Returned as ``{"address": }``. :raises: BadArgumentError: if an argument is malformed, missing, or conflicts. """ + if bip388_policy is not None: + if path is not None or desc is not None: + raise BadArgumentError("BIP388 policy display is mutually exclusive with path and descriptor") + if not bip388_policy.name: + raise BadArgumentError("Missing --policy-name") + if bip388_policy.descriptor_template is None: + raise BadArgumentError("Missing --policy-desc") + if bip388_policy.keys_info is None or len(bip388_policy.keys_info) == 0: + raise BadArgumentError("Missing --key") + if index is None: + raise BadArgumentError("Missing --index") + if index < 0: + raise BadArgumentError("Address index must be non-negative") + + address = client.display_bip388_policy_address(bip388_policy, index, change) + return { + "address": address, + "index": index, + "change": change, + } if path is not None: return {"address": client.display_singlesig_address(path, addr_type)} elif desc is not None: @@ -492,7 +527,24 @@ def displayaddress( elif isinstance(descriptor, TRDescriptor): addr_type = AddressType.TAP return {"address": client.display_singlesig_address(pubkey.get_full_derivation_path(0), addr_type)} - raise BadArgumentError("Missing both path and descriptor") + raise BadArgumentError("Missing path, descriptor, or BIP388 policy") + +def register( + client: HardwareWalletClient, + bip388_policy: BIP388Policy, +) -> Dict[str, str]: + """ + Register a BIP388 policy on the device for client. + + :param name: Name for the policy + :param desc: Descriptor template + :return: A dictionary containing policy HMAC. + Returned as ``{"hmac": }``. + :raises: BadArgumentError: if an argument is malformed, missing, or conflicts. + """ + assert bip388_policy.hmac is None + + return {"hmac": client.register_bip388_policy(bip388_policy)} def setup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]: """ 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..c3bed4230 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, + bip388_policy: Optional[BIP388Policy] = None, + ) -> PSBT: """ Sign a transaction with the BitBox02. @@ -573,6 +578,9 @@ def sign_tx(self, psbt: PSBT) -> PSBT: Transactions with legacy inputs are not supported. """ + if bip388_policy is not None: + raise UnavailableActionError("BitBox02 does not support BIP388 policy signing") + def find_our_key( keypaths: Dict[bytes, KeyOriginInfo] ) -> Tuple[Optional[bytes], Optional[Sequence[int]]]: diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index 1efe889b9..04fb93335 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 @@ -133,7 +134,11 @@ def get_master_fingerprint(self) -> bytes: return struct.pack(' PSBT: + def sign_tx( + self, + psbt: PSBT, + bip388_policy: Optional[BIP388Policy] = None, + ) -> PSBT: """ Sign a transaction with the Coldcard. @@ -141,6 +146,9 @@ def sign_tx(self, tx: PSBT) -> PSBT: - 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 bip388_policy is not None: + raise UnavailableActionError("Coldcard does not support BIP388 policy signing") + self.device.check_mitm() # Get this devices master key fingerprint @@ -150,7 +158,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 if not self.is_edge: - 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] @@ -161,8 +169,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) @@ -208,10 +216,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..a12489539 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,23 @@ 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, + bip388_policy: Optional[BIP388Policy] = None, + ) -> PSBT: + if bip388_policy is not None: + raise UnavailableActionError("Digital Bitbox does not support BIP388 policy signing") # 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 +500,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 +540,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..10151c58d 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,23 @@ 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, + bip388_policy: Optional[BIP388Policy] = None, + ) -> PSBT: """ Sign a transaction with the Blockstream Jade. """ + if bip388_policy is not None: + raise UnavailableActionError("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(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 97eba1ef0..1380e149e 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -31,6 +31,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) from .ledger_bitcoin.client import ( @@ -39,7 +40,7 @@ LegacyClient, TransportClient, ) -from .ledger_bitcoin.client_base import ApduException +from .ledger_bitcoin.client_base import ApduException, MusigPubNonce, MusigPartialSignature from .ledger_bitcoin.exception import NotSupportedError from .ledger_bitcoin.wallet import ( MultisigWallet, @@ -122,6 +123,19 @@ def check_keypath(key_path: str) -> bool: AddressType.LEGACY: 3, } +_BIP388_HARDENED_MARKER_RE = re.compile(r"(?<=/)(\d+)[hH](?=/|$)") + +def _normalize_bip388_key_info(keys_info: List[str]) -> List[str]: + def normalize_origin(match: re.Match[str]) -> str: + return "[" + _BIP388_HARDENED_MARKER_RE.sub(r"\1'", match.group(1)) + "]" + + # The Ledger app computes the wallet id over literal key-info strings and + # only accepts apostrophes for hardened derivation steps. Normalize h/H + # markers in origin paths consistently across register/sign/display. An + # hmac registered elsewhere for a policy that kept h/H markers will not + # verify against this normalized policy identity. + return [re.sub(r"\[([^\]]+)\]", normalize_origin, key_info, count=1) for key_info in keys_info] + def handle_chip_exception(e: Union[BTChipException, ApduException], func_name: str) -> bool: if e.sw in bad_args: raise BadArgumentError('Bad argument') @@ -186,7 +200,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] = None + ) -> PSBT: """ Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger. @@ -199,6 +217,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() @@ -207,22 +227,38 @@ 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.partial_sigs[pubkey] = sig - return tx + for idx, partial_sig in legacy_input_sigs: + psbt_in = psbt.inputs[idx] + psbt_in.partial_sigs[partial_sig.pubkey] = partial_sig.signature + 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() + if bip388_policy is not None: + # The Ledger Bitcoin app refuses to yield its own MuSig2 pub nonce + # in round 1 if another participant's pub nonce is already present + # in an input, and likewise refuses to yield its partial signature + # in round 2 if another partial signature is present. Strip those + # entries from the copy handed to the device; the caller's PSBT + # keeps them and the device's fresh contributions are merged back + # at the end. Peer pub nonces are kept in round 2 because the + # device needs them to compute its partial signature. Round + # heuristic: any partial sig present means round 2. + for psbt_in in psbt2.inputs: + if psbt_in.musig2_partial_sigs: + psbt_in.musig2_partial_sigs.clear() + else: + psbt_in.musig2_pub_nonces.clear() + # Figure out which wallets are signing wallets: Dict[bytes, Tuple[int, AddressType, WalletPolicy, Optional[bytes]]] = {} pubkeys: Dict[int, bytes] = {} @@ -265,6 +301,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=_normalize_bip388_key_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: @@ -347,36 +402,59 @@ def process_origin(origin: KeyOriginInfo) -> None: if not is_wit: psbt_in.witness_utxo = None - input_sigs = self.client.sign_psbt(psbt2, wallet, wallet_hmac) + res = self.client.sign_psbt(psbt2, wallet, wallet_hmac) - for idx, pubkey, sig in input_sigs: + for idx, yielded in res: psbt_in = psbt2.inputs[idx] - utxo = None - if psbt_in.witness_utxo: - utxo = psbt_in.witness_utxo - if psbt_in.non_witness_utxo: - assert psbt_in.prev_out is not None - utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] - assert utxo is not None + if isinstance(yielded, MusigPubNonce): + psbt_key = ( + yielded.participant_pubkey, + yielded.aggregate_pubkey, + yielded.tapleaf_hash + ) - is_wit, wit_ver, _ = utxo.is_witness() + assert len(yielded.aggregate_pubkey) == 33 - if is_wit and wit_ver >= 1: - # TODO: Deal with script path signatures - # For now, assume key path signature - psbt_in.tap_key_sig = sig + psbt_in.musig2_pub_nonces[psbt_key] = yielded.pubnonce + elif isinstance(yielded, MusigPartialSignature): + psbt_key = ( + yielded.participant_pubkey, + yielded.aggregate_pubkey, + yielded.tapleaf_hash + ) + + psbt_in.musig2_partial_sigs[psbt_key] = yielded.partial_signature else: - psbt_in.partial_sigs[pubkey] = sig + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if psbt_in.non_witness_utxo: + assert psbt_in.prev_out is not None + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + assert utxo is not None + + is_wit, wit_ver, _ = utxo.is_witness() + + if is_wit and wit_ver >= 1: + if yielded.tapleaf_hash is None: + psbt_in.tap_key_sig = yielded.signature + else: + psbt_in.tap_script_sigs[(yielded.pubkey, yielded.tapleaf_hash)] = yielded.signature + + else: + psbt_in.partial_sigs[yielded.pubkey] = yielded.signature # 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.musig2_pub_nonces.update(sig_in.musig2_pub_nonces) + psbt_in.musig2_partial_sigs.update(sig_in.musig2_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: @@ -480,6 +558,45 @@ def format_key_info(pubkey: PubkeyProvider) -> str: return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) + @ledger_exception + def register_bip388_policy( + self, + bip388_policy: BIP388Policy, + ) -> str: + if isinstance(self.client, LegacyClient): + raise BadArgumentError("Registering a BIP388 policy not supported by this version of the Bitcoin App") + + wallet_policy = WalletPolicy( + name=bip388_policy.name, + descriptor_template=bip388_policy.descriptor_template, + keys_info=_normalize_bip388_key_info(bip388_policy.keys_info) + ) + + _, registered_hmac = self.client.register_wallet(wallet_policy) + + return registered_hmac.hex() + + @ledger_exception + def display_bip388_policy_address( + self, + bip388_policy: BIP388Policy, + index: int, + change: bool = False, + ) -> str: + if isinstance(self.client, LegacyClient): + raise BadArgumentError("Displaying a BIP388 policy address is not supported by this version of the Bitcoin App") + if index < 0: + raise BadArgumentError("Address index must be non-negative") + + wallet_policy = WalletPolicy( + name=bip388_policy.name, + descriptor_template=bip388_policy.descriptor_template, + keys_info=_normalize_bip388_key_info(bip388_policy.keys_info) + ) + + wallet_hmac = None if bip388_policy.hmac is None else bytes.fromhex(bip388_policy.hmac) + return self.client.get_wallet_address(wallet_policy, wallet_hmac, 1 if change else 0, index, True) + def setup_device(self, label: str = "", passphrase: str = "") -> bool: """ Ledgers do not support setup via software. diff --git a/hwilib/devices/ledger_bitcoin/README.md b/hwilib/devices/ledger_bitcoin/README.md index e3cc0ee60..f91bdd64a 100644 --- a/hwilib/devices/ledger_bitcoin/README.md +++ b/hwilib/devices/ledger_bitcoin/README.md @@ -1,8 +1,8 @@ # Ledger Bitcoin application client -This is a stripped down version of the client provided at https://github.com/LedgerHQ/app-bitcoin-new/tree/master/bitcoin_client. +This is a stripped down version of the client provided at https://github.com/LedgerHQ/app-bitcoin/tree/master/bitcoin_client. -This stripped down version was made at commit [4e82e44ecfe4ba358da9848087e7e597309abc53](https://github.com/LedgerHQ/app-bitcoin-new/commit/4e82e44ecfe4ba358da9848087e7e597309abc53) +This stripped down version was made at commit [4e82e44ecfe4ba358da9848087e7e597309abc53](https://github.com/LedgerHQ/app-bitcoin/commit/4e82e44ecfe4ba358da9848087e7e597309abc53) ## Changes diff --git a/hwilib/devices/ledger_bitcoin/client.py b/hwilib/devices/ledger_bitcoin/client.py index 64bbe3adc..219b78f76 100644 --- a/hwilib/devices/ledger_bitcoin/client.py +++ b/hwilib/devices/ledger_bitcoin/client.py @@ -4,9 +4,10 @@ from .command_builder import BitcoinCommandBuilder, BitcoinInsType from ...common import Chain -from .client_command import ClientCommandInterpreter -from .client_base import Client, TransportClient +from .client_command import ClientCommandInterpreter, CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG, CCMD_YIELD_MUSIG_PUBNONCE_TAG +from .client_base import Client, MusigPartialSignature, MusigPubNonce, PartialSignature, SignPsbtYieldedObject, TransportClient from .client_legacy import LegacyClient +from .errors import UnknownDeviceError from .exception import DeviceException, NotSupportedError from .merkle import get_merkleized_map_commitment from .wallet import WalletPolicy, WalletType @@ -31,6 +32,74 @@ def parse_stream_to_map(f: BufferedReader) -> Mapping[bytes, bytes]: result[key] = value return result +def _make_partial_signature(pubkey_augm: bytes, signature: bytes) -> PartialSignature: + if len(pubkey_augm) == 64: + # tapscript spend: pubkey_augm is the concatenation of: + # - a 32-byte x-only pubkey + # - the 32-byte tapleaf_hash + return PartialSignature(signature=signature, pubkey=pubkey_augm[0:32], tapleaf_hash=pubkey_augm[32:]) + + else: + # either legacy, segwit or taproot keypath spend + # pubkey must be 32 (taproot x-only pubkey) or 33 bytes (compressed pubkey) + + if len(pubkey_augm) not in [32, 33]: + raise UnknownDeviceError(f"Invalid pubkey length returned: {len(pubkey_augm)}") + + return PartialSignature(signature=signature, pubkey=pubkey_augm) + +def _decode_signpsbt_yielded_value(res: bytes) -> Tuple[int, SignPsbtYieldedObject]: + res_buffer = BytesIO(res) + input_index_or_tag = read_varint(res_buffer) + if input_index_or_tag == CCMD_YIELD_MUSIG_PUBNONCE_TAG: + input_index = read_varint(res_buffer) + pubnonce = res_buffer.read(66) + participant_pk = res_buffer.read(33) + aggregate_pubkey = res_buffer.read(33) + tapleaf_hash = res_buffer.read() + if len(tapleaf_hash) == 0: + tapleaf_hash = None + + return ( + input_index, + MusigPubNonce( + participant_pubkey=participant_pk, + aggregate_pubkey=aggregate_pubkey, + tapleaf_hash=tapleaf_hash, + pubnonce=pubnonce + ) + ) + elif input_index_or_tag == CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG: + input_index = read_varint(res_buffer) + partial_signature = res_buffer.read(32) + participant_pk = res_buffer.read(33) + aggregate_pubkey = res_buffer.read(33) + tapleaf_hash = res_buffer.read() + if len(tapleaf_hash) == 0: + tapleaf_hash = None + + return ( + input_index, + MusigPartialSignature( + participant_pubkey=participant_pk, + aggregate_pubkey=aggregate_pubkey, + tapleaf_hash=tapleaf_hash, + partial_signature=partial_signature + ) + ) + else: + # other values follow an encoding without an explicit tag, where the + # first element is the input index. All the signature types are implemented + # by the PartialSignature type (not to be confused with the musig Partial Signature). + input_index = input_index_or_tag + + pubkey_augm_len = read_uint(res_buffer, 8) + pubkey_augm = res_buffer.read(pubkey_augm_len) + + signature = res_buffer.read() + + return((input_index, _make_partial_signature(pubkey_augm, signature))) + def read_uint(buf: BytesIO, bit_len: int, @@ -156,7 +225,7 @@ def get_wallet_address( return response.decode() - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: """Signs a PSBT using a registered wallet (or a standard wallet that does not need registration). Signature requires explicit approval from the user. @@ -240,17 +309,10 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte if any(len(x) <= 1 for x in results): raise RuntimeError("Invalid response") - results_list: List[Tuple[int, bytes, bytes]] = [] + results_list: List[Tuple[int, SignPsbtYieldedObject]] = [] for res in results: - res_buffer = BytesIO(res) - input_index = read_varint(res_buffer) - - pubkey_len = read_uint(res_buffer, 8) - pubkey = res_buffer.read(pubkey_len) - - signature = res_buffer.read() - - results_list.append((input_index, pubkey, signature)) + input_index, obj = _decode_signpsbt_yielded_value(res) + results_list.append((input_index, obj)) return results_list diff --git a/hwilib/devices/ledger_bitcoin/client_base.py b/hwilib/devices/ledger_bitcoin/client_base.py index 5b846963f..6020043eb 100644 --- a/hwilib/devices/ledger_bitcoin/client_base.py +++ b/hwilib/devices/ledger_bitcoin/client_base.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from typing import Tuple, Optional, Union, List from io import BytesIO @@ -45,6 +47,60 @@ def apdu_exchange_nowait( def stop(self) -> None: self.transport.close() +@dataclass(frozen=True) +class PartialSignature: + """Represents a partial signature returned by sign_psbt. Such objects can be added to the PSBT. + + It always contains a pubkey and a signature. + The pubkey is a compressed 33-byte for legacy and segwit Scripts, or 32-byte x-only key for taproot. + The signature is in the format it would be pushed on the scriptSig or the witness stack, therefore of + variable length, and possibly concatenated with the SIGHASH flag byte if appropriate. + + The tapleaf_hash is also filled if signing for a tapscript. + + Note: not to be confused with 'partial signature' of protocols like MuSig2; + """ + pubkey: bytes + signature: bytes + tapleaf_hash: Optional[bytes] = None + + +@dataclass(frozen=True) +class MusigPubNonce: + """Represents a pubnonce returned by sign_psbt during the first round of a Musig2 signing session. + + It always contains + - the participant_pubkey, a 33-byte compressed pubkey; + - aggregate_pubkey, the 33-byte compressed pubkey key that is the aggregate of all the participant + pubkeys, with the necessary tweaks; its x-only version is the key present in the Script; + - the 66-byte pubnonce. + + The tapleaf_hash is also filled if signing for a tapscript; `None` otherwise. + """ + participant_pubkey: bytes + aggregate_pubkey: bytes + tapleaf_hash: Optional[bytes] + pubnonce: bytes + + +@dataclass(frozen=True) +class MusigPartialSignature: + """Represents a partial signature returned by sign_psbt during the second round of a Musig2 signing session. + + It always contains + - the participant_pubkey, a 33-byte compressed pubkey; + - aggregate_pubkey, the 33-byte compressed pubkey key that is the aggregate of all the participant + pubkeys, with the necessary tweaks; its x-only version is the key present in the Script; + - the partial_signature, the 32-byte partial signature for this participant. + + The tapleaf_hash is also filled if signing for a tapscript; `None` otherwise + """ + participant_pubkey: bytes + aggregate_pubkey: bytes + tapleaf_hash: Optional[bytes] + partial_signature: bytes + +SignPsbtYieldedObject = Union[PartialSignature, MusigPubNonce, MusigPartialSignature] class Client: def __init__(self, transport_client: TransportClient, chain: Chain = Chain.MAIN) -> None: @@ -183,18 +239,19 @@ def get_wallet_address( raise NotImplementedError - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: Union[PSBT, bytes, str], wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: """Signs a PSBT using a registered wallet (or a standard wallet that does not need registration). Signature requires explicit approval from the user. Parameters ---------- - psbt : PSBT + psbt : PSBT | bytes | str A PSBT of version 0 or 2, with all the necessary information to sign the inputs already filled in; what the required fields changes depending on the type of input. The non-witness UTXO must be present for both legacy and SegWit inputs, or the hardware wallet will reject signing (this will change for Taproot inputs). + The argument can be either a `PSBT` object, or `bytes`, or a base64-encoded `str`. wallet : WalletPolicy The registered wallet policy, or a standard wallet policy. @@ -204,11 +261,10 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte Returns ------- - List[Tuple[int, bytes, bytes]] + List[Tuple[int, PartialSignature]] A list of tuples returned by the hardware wallets, where each element is a tuple of: - an integer, the index of the input being signed; - - a `bytes` array of length 33 (compressed ecdsa pubkey) or 32 (x-only BIP-0340 pubkey), the corresponding pubkey for this signature; - - a `bytes` array with the signature. + - an instance of `PartialSignature`. """ raise NotImplementedError diff --git a/hwilib/devices/ledger_bitcoin/client_command.py b/hwilib/devices/ledger_bitcoin/client_command.py index 9fd57c464..7a01167e4 100644 --- a/hwilib/devices/ledger_bitcoin/client_command.py +++ b/hwilib/devices/ledger_bitcoin/client_command.py @@ -46,6 +46,8 @@ class ClientCommandCode(IntEnum): GET_MERKLE_LEAF_INDEX = 0x42 GET_MORE_ELEMENTS = 0xA0 +CCMD_YIELD_MUSIG_PUBNONCE_TAG = 0xFFFFFFFF +CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG = 0xFFFFFFFE class ClientCommand: def execute(self, request: bytes) -> bytes: @@ -350,7 +352,7 @@ def add_known_mapping(self, mapping: Mapping[bytes, bytes]) -> None: of a mapping of bytes to bytes. Adds the Merkle tree of the list of keys, and the Merkle tree of the list of corresponding - values, with the same semantics as the `add_known_list` applied separately to the two lists. + values, with the same semantics as the `add_known_list` applied separately to the two lists. Parameters ---------- diff --git a/hwilib/devices/ledger_bitcoin/client_legacy.py b/hwilib/devices/ledger_bitcoin/client_legacy.py index b545fe593..8089ed5c0 100644 --- a/hwilib/devices/ledger_bitcoin/client_legacy.py +++ b/hwilib/devices/ledger_bitcoin/client_legacy.py @@ -10,7 +10,7 @@ import re import base64 -from .client import Client, TransportClient +from .client import Client, PartialSignature, SignPsbtYieldedObject, TransportClient from typing import List, Tuple, Optional, Union @@ -137,7 +137,7 @@ def get_wallet_address( return output['address'][12:-2] # HACK: A bug in getWalletPublicKey results in the address being returned as the string "bytearray(b'
')". This extracts the actual address to work around this. # NOTE: This is different from the new API, but we need it for multisig support. - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: if wallet_hmac is not None or wallet.n_keys != 1: raise NotImplementedError("Policy wallets are only supported from version 2.0.0. Please update your Ledger hardware wallet") @@ -259,7 +259,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte all_signature_attempts[i_num] = signature_attempts - result: List[int, bytes, bytes] = [] + result: List[int, SignPsbtYieldedObject] = [] # Sign any segwit inputs if has_segwit: @@ -276,7 +276,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte for signature_attempt in all_signature_attempts[i]: self.app.startUntrustedTransaction(False, 0, [segwit_inputs[i]], script_codes[i], c_tx.nVersion) - result.append((i, signature_attempt[1], self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01))) + result.append((i, PartialSignature(pubkey=signature_attempt[1], signature=self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01)))) elif has_legacy: first_input = True @@ -287,7 +287,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte self.app.startUntrustedTransaction(first_input, i, legacy_inputs, script_codes[i], c_tx.nVersion) self.app.finalizeInput(b"DUMMY", -1, -1, change_path, tx_bytes) - result.append((i, signature_attempt[1], self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01))) + result.append((i, PartialSignature(pubkey=signature_attempt[1], signature=self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01)))) first_input = False diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index bd5a79733..3bf419444 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, + bip388_policy: Optional[BIP388Policy] = None, + ) -> PSBT: """ Sign a transaction with the Trezor. There are some limitations to what transactions can be signed. @@ -364,6 +369,9 @@ def sign_tx(self, tx: 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 bip388_policy is not None: + raise UnavailableActionError("Trezor does not support BIP388 policy signing") + self._check_unlocked() # Get this devices master key fingerprint @@ -378,7 +386,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 +451,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 +537,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 +586,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 +597,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 +626,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 +654,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..d57e0cc9e 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, + BIP388Policy, + Chain, +) 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] = None + ) -> PSBT: """ Sign a partially signed bitcoin transaction (PSBT). @@ -135,6 +143,33 @@ def display_multisig_address( raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") + def display_bip388_policy_address( + self, + bip388_policy: BIP388Policy, + index: int, + change: bool = False, + ) -> str: + """ + Display and return the address from a registered BIP388 policy. + + :param bip388_policy: The BIP388 policy, optionally including HMAC + :param index: The address index to display + :param change: Whether to display the change address + :return: The retrieved address also being shown by the device + """ + raise NotImplementedError("This device does not support BIP388 policies or it's not yet implemented") + + def register_bip388_policy( + self, + bip388_policy: BIP388Policy, + ) -> str: + """ + Register a BIP388 policy. + + :return: The policy HMAC + """ + raise NotImplementedError("This device does not support BIP388 policies or it's not yet implemented") + def wipe_device(self) -> bool: """ Wipe the device. diff --git a/hwilib/psbt.py b/hwilib/psbt.py index 28b5e1888..9600ae59d 100644 --- a/hwilib/psbt.py +++ b/hwilib/psbt.py @@ -1046,8 +1046,11 @@ def cache_unsigned_tx_pieces(self) -> None: Does nothing if the PSBT is already v2. """ # To make things easier, we split up the global transaction - # and use the PSBTv2 fields for PSBTv0 - if self.tx is not None: + # and use the PSBTv2 fields for PSBTv0. + # Don't check self.tx instead of self.version: for PSBTv2 it is + # merely an empty placeholder, and its default version and locktime + # would overwrite the deserialized values. + if self.version == 0: self.setup_from_tx(self.tx) def setup_from_tx(self, tx: CTransaction): diff --git a/poetry.lock b/poetry.lock index 88af47e0e..a11e72cbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1311,4 +1311,4 @@ qt = ["pyside2"] [metadata] lock-version = "2.1" python-versions = "^3.9,<3.13" -content-hash = "ffa2aebd594ec1a5db15d7325c7bb26036b593faccf363163379e276bff1c191" +content-hash = "278dc524a94cc0c503225e4a20f353bd09fa3deaab956e9d559c3795a304ad65" diff --git a/pyproject.toml b/pyproject.toml index ab98299ba..034ae7935 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ libusb1 = ">=1.7,<4" pyside2 = { version = "^5.14.0", optional = true, python = "<3.10" } cbor2 = ">=5.4.6,<5.8" pyserial = "^3.5" -dataclasses = {version = "^0.8", python = ">=3.6,<3.7"} semver = "^3.0.1" noiseprotocol = "^0.3.1" protobuf = "^4.23.3" diff --git a/setup.py b/setup.py index e821a4ca2..d6255f68e 100644 --- a/setup.py +++ b/setup.py @@ -39,8 +39,7 @@ 'typing-extensions>=4.4,<5.0'] extras_require = \ -{':python_version == "3.6"': ['dataclasses>=0.8,<0.9'], - 'qt:python_version < "3.10"': ['pyside2>=5.14.0,<6.0.0']} +{'qt:python_version < "3.10"': ['pyside2>=5.14.0,<6.0.0']} entry_points = \ {'console_scripts': ['hwi = hwilib._cli:main', 'hwi-qt = hwilib._gui:main']} @@ -49,7 +48,7 @@ 'name': 'hwi', 'version': '3.2.0', 'description': 'A library for working with Bitcoin hardware wallets', - 'long_description': "# Bitcoin Hardware Wallet Interface\n\n[![Build Status](https://api.cirrus-ci.com/github/bitcoin-core/HWI.svg)](https://cirrus-ci.com/github/bitcoin-core/HWI)\n[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest)\n\nThe Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets.\nIt provides a standard way for software to work with hardware wallets without needing to implement device specific drivers.\nPython software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool.\n\nCaveat emptor: Inclusion of a specific hardware wallet vendor does not imply any endorsement of quality or security.\n\n## Prerequisites\n\nPython 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed\n\nFor Ubuntu/Debian:\n```\nsudo apt install libusb-1.0-0-dev libudev-dev python3-dev\n```\n\nFor Centos:\n```\nsudo yum -y install python3-devel libusbx-devel systemd-devel\n```\n\nFor macOS:\n```\nbrew install libusb\n```\n\n## Install\n\n```\ngit clone https://github.com/bitcoin-core/HWI.git\ncd HWI\npoetry install # or 'pip3 install .' or 'python3 setup.py install'\n```\n\nThis project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. HWI and its dependencies can be installed via poetry by executing the following in the root source directory:\n\n```\npoetry install\n```\n\nPip can also be used to automatically install HWI and its dependencies using the `setup.py` file (which is usually in sync with `pyproject.toml`):\n\n```\npip3 install .\n```\n\nThe `setup.py` file can be used to install HWI and its dependencies so long as `setuptools` is also installed:\n\n```\npip3 install -U setuptools\npython3 setup.py install\n```\n\n## Dependencies\n\nSee `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependencies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. These dependencies will be installed with any of the three above installation methods.\n\n## Usage\n\nTo use, first enumerate all devices and find the one that you want to use with\n\n```\n./hwi.py enumerate\n```\n\nOnce the device type and device path are known, issue commands to it like so:\n\n```\n./hwi.py -t -d \n```\n\nAll output will be in JSON form and sent to `stdout`.\nAdditional information or prompts will be sent to `stderr` and will not necessarily be in JSON.\nThis additional information is for debugging purposes.\n\nTo see a complete list of available commands and global parameters, run\n`./hwi.py --help`. To see options specific to a particular command,\npass the `--help` parameter after the command name; for example:\n\n```\n./hwi.py getdescriptors --help\n```\n\n## Documentation\n\nDocumentation for HWI can be found on [readthedocs.io](https://hwi.readthedocs.io/).\n\n### Device Support\n\nFor documentation on devices supported and how they are supported, please check the [device support page](https://hwi.readthedocs.io/en/latest/devices/index.html#support-matrix)\n\n### Using with Bitcoin Core\n\nSee [Using Bitcoin Core with Hardware Wallets](https://hwi.readthedocs.io/en/latest/examples/bitcoin-core-usage.html).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", + 'long_description': "# Bitcoin Hardware Wallet Interface\n\n[![CI](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml/badge.svg)](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml)\n[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest)\n\nThe Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets.\nIt provides a standard way for software to work with hardware wallets without needing to implement device specific drivers.\nPython software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool.\n\nCaveat emptor: Inclusion of a specific hardware wallet vendor does not imply any endorsement of quality or security.\n\n## Prerequisites\n\nPython 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed\n\nFor Ubuntu/Debian:\n```\nsudo apt install libusb-1.0-0-dev libudev-dev python3-dev\n```\n\nFor Centos:\n```\nsudo yum -y install python3-devel libusbx-devel systemd-devel\n```\n\nFor macOS:\n```\nbrew install libusb\n```\n\n## Install\n\n```\ngit clone https://github.com/bitcoin-core/HWI.git\ncd HWI\npoetry install # or 'pip3 install .' or 'python3 setup.py install'\n```\n\nThis project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. HWI and its dependencies can be installed via poetry by executing the following in the root source directory:\n\n```\npoetry install\n```\n\nPip can also be used to automatically install HWI and its dependencies using the `setup.py` file (which is usually in sync with `pyproject.toml`):\n\n```\npip3 install .\n```\n\nThe `setup.py` file can be used to install HWI and its dependencies so long as `setuptools` is also installed:\n\n```\npip3 install -U setuptools\npython3 setup.py install\n```\n\n## Dependencies\n\nSee `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependencies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. These dependencies will be installed with any of the three above installation methods.\n\n## Usage\n\nTo use, first enumerate all devices and find the one that you want to use with\n\n```\n./hwi.py enumerate\n```\n\nOnce the device type and device path are known, issue commands to it like so:\n\n```\n./hwi.py -t -d \n```\n\nAll output will be in JSON form and sent to `stdout`.\nAdditional information or prompts will be sent to `stderr` and will not necessarily be in JSON.\nThis additional information is for debugging purposes.\n\nTo see a complete list of available commands and global parameters, run\n`./hwi.py --help`. To see options specific to a particular command,\npass the `--help` parameter after the command name; for example:\n\n```\n./hwi.py getdescriptors --help\n```\n\n## Documentation\n\nDocumentation for HWI can be found on [readthedocs.io](https://hwi.readthedocs.io/).\n\n### Device Support\n\nFor documentation on devices supported and how they are supported, please check the [device support page](https://hwi.readthedocs.io/en/latest/devices/index.html#support-matrix)\n\n### Using with Bitcoin Core\n\nSee [Using Bitcoin Core with Hardware Wallets](https://hwi.readthedocs.io/en/latest/examples/bitcoin-core-usage.html).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", 'author': 'Ava Chow', 'author_email': 'me@achow101.com', 'maintainer': 'None', diff --git a/test/data/speculos-automation.json b/test/data/speculos-automation.json index f17a9c668..ad52974e0 100644 --- a/test/data/speculos-automation.json +++ b/test/data/speculos-automation.json @@ -28,7 +28,14 @@ ] }, { - "regexp": "^(Address|Review|Amount|Fee|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Spend from|Wallet name|Wallet policy|Descriptor template|Verify Bitcoin|To|Output|Warning).*", + "regexp": "^(Address|Review|Amount|Fee|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Spend from|Account|Wallet name|Wallet policy|Descriptor template|Verify Bitcoin|To|Output|Warning).*", + "actions": [ + [ "button", 2, true ], + [ "button", 2, false ] + ] + }, + { + "regexp": "^From$", "actions": [ [ "button", 2, true ], [ "button", 2, false ] diff --git a/test/run_ledger_musig2_scenario.sh b/test/run_ledger_musig2_scenario.sh new file mode 100755 index 000000000..2bc7f17d6 --- /dev/null +++ b/test/run_ledger_musig2_scenario.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# End-to-end MuSig2 / BIP388 scenario for Python HWI against the Ledger +# Bitcoin app under Speculos and a MuSig2-capable Bitcoin Core branch. +# +# This test requires Bitcoin Core functionality that is not in upstream +# Bitcoin Core v31. +# +# Env: +# BITCOIND, BITCOIN_CLI MuSig2-capable bitcoind/cli +# HWI_REPO Python HWI checkout +# LEDGER_APP_ELF Ledger Bitcoin app .elf +# PYTHON, SPECULOS Commands used to run Python / Speculos +# KEEP_DATADIR set to keep the regtest datadir on exit + +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BITCOIND="${BITCOIND:-$REPO_ROOT/test/work/bitcoin/build/bin/bitcoind}" +BITCOIN_CLI="${BITCOIN_CLI:-$REPO_ROOT/test/work/bitcoin/build/bin/bitcoin-cli}" +HWI_REPO="${HWI_REPO:-$REPO_ROOT}" +LEDGER_APP_ELF="${LEDGER_APP_ELF:-$REPO_ROOT/test/work/speculos/apps/btc-test.elf}" +AUTOMATION_JSON="${AUTOMATION_JSON:-$REPO_ROOT/test/data/speculos-automation.json}" + +if [[ -z "${PYTHON:-}" && -x "$HOME/.pyenv/bin/pyenv" ]]; then + PYTHON="env PYENV_VERSION=3.10.14 $HOME/.pyenv/bin/pyenv exec python" +else + PYTHON="${PYTHON:-python}" +fi +if [[ -z "${SPECULOS:-}" && -x "$HOME/.pyenv/bin/pyenv" ]]; then + SPECULOS="env PYENV_VERSION=3.10.14 $HOME/.pyenv/bin/pyenv exec speculos" +else + SPECULOS="${SPECULOS:-speculos}" +fi +read -r -a PYTHON_CMD <<< "$PYTHON" +read -r -a SPECULOS_CMD <<< "$SPECULOS" + +APDU_PORT=9999 +SPECULOS_API_PORT=5000 +RPCPORT=28453 +P2PPORT=28454 +POLICY_NAME="MuSigTest" +WALLET_NAME="musig_hww" + +hwi() { + "${PYTHON_CMD[@]}" "$HWI_REPO/hwi.py" --emulators "$@" +} + +for f in "$BITCOIND" "$BITCOIN_CLI" "$LEDGER_APP_ELF" "$AUTOMATION_JSON" "$HWI_REPO/hwi.py"; do + [[ -e "$f" ]] || { echo "missing file: $f" >&2; exit 1; } +done + +DATADIR="$(mktemp -d)" +SPECULOS_LOG="$DATADIR/speculos.log" +SIGNER="$DATADIR/hwi-signer.sh" +SPECULOS_PID="" + +cat >"$SIGNER" <&2 + tail -25 "$DATADIR/regtest/debug.log" 2>/dev/null >&2 || true + echo "=== recent speculos log:" >&2 + tail -15 "$SPECULOS_LOG" 2>/dev/null >&2 || true + fi + "$BITCOIN_CLI" -regtest -datadir="$DATADIR" -rpcport="$RPCPORT" stop >/dev/null 2>&1 || true + if [[ -n "${SPECULOS_PID:-}" ]]; then + kill "$SPECULOS_PID" 2>/dev/null || true + wait "$SPECULOS_PID" 2>/dev/null || true + fi + sleep 1 + if [[ -z "${KEEP_DATADIR:-}" ]]; then + rm -rf "$DATADIR" + else + echo "KEEP_DATADIR set; leaving $DATADIR in place" >&2 + fi +} +trap cleanup_all EXIT + +echo "== launching speculos with $LEDGER_APP_ELF" +# Drive device confirmations with speculos' content-aware automation +# (the same rules the device test suite uses) instead of blindly +# spamming buttons: a timed button loop can press "both" while a +# Reject/Cancel screen is showing and abort signing, which makes the +# MuSig2 device round fail intermittently. +"${SPECULOS_CMD[@]}" \ + --model nanox \ + --display headless \ + --apdu-port "$APDU_PORT" \ + --api-port "$SPECULOS_API_PORT" \ + --automation "file:$AUTOMATION_JSON" \ + --log-level automation:DEBUG \ + "$LEDGER_APP_ELF" \ + >"$SPECULOS_LOG" 2>&1 & +SPECULOS_PID=$! + +echo "== waiting for speculos APDU port" +for _ in $(seq 1 60); do + (echo > "/dev/tcp/127.0.0.1/$APDU_PORT") 2>/dev/null && break + sleep 1 +done +(echo > "/dev/tcp/127.0.0.1/$APDU_PORT") 2>/dev/null || { + echo "speculos failed to come up; log:" >&2 + cat "$SPECULOS_LOG" >&2 || true + exit 1 +} + +echo "== probing speculos via hwi enumerate" +FP_A="$(hwi enumerate | "${PYTHON_CMD[@]}" -c ' +import json, sys +entries = json.load(sys.stdin) +entries = [e for e in entries if "fingerprint" in e] +assert len(entries) == 1, f"expected one device, got {entries!r}" +print(entries[0]["fingerprint"]) +')" +echo "speculos master fingerprint: $FP_A" + +echo "== fetching speculos xpub at m/87h/1h/0h" +COSIGNER_A_XPUB="$(hwi --fingerprint "$FP_A" --chain test getxpub "m/87h/1h/0h" \ + | "${PYTHON_CMD[@]}" -c 'import json,sys; print(json.load(sys.stdin)["xpub"])')" +COSIGNER_A_KEY="[${FP_A}/87h/1h/0h]${COSIGNER_A_XPUB}" +echo "cosigner A: $COSIGNER_A_KEY" + +echo "== launching bitcoind (regtest) with -signer=$SIGNER" +"$BITCOIND" -regtest -datadir="$DATADIR" -daemon \ + -signer="$SIGNER" \ + -fallbackfee=0.0001 \ + -rpcport="$RPCPORT" -port="$P2PPORT" -listen=0 + +core_cli() { "$BITCOIN_CLI" -regtest -datadir="$DATADIR" -rpcport="$RPCPORT" "$@"; } +wallet_cli() { core_cli -rpcwallet="$WALLET_NAME" "$@"; } +miner_cli() { core_cli -rpcwallet=miner "$@"; } + +echo "== waiting for RPC" +for _ in $(seq 1 30); do + core_cli getblockchaininfo >/dev/null 2>&1 && break + sleep 1 +done +core_cli getblockchaininfo >/dev/null + +echo "== createwallet $WALLET_NAME (external_signer, blank, private keys enabled)" +core_cli -named createwallet \ + wallet_name="$WALLET_NAME" \ + descriptors=true \ + disable_private_keys=false \ + external_signer=true \ + blank=true >/dev/null + +echo "== addhdkey + derivehdkey for hot cosigner B at m/87h/1h/0h" +wallet_cli addhdkey >/dev/null +COSIGNER_B_KEY="$(wallet_cli -named derivehdkey path="m/87h/1h/0h" \ + | "${PYTHON_CMD[@]}" -c 'import json,sys; v=json.load(sys.stdin); print(v["origin"] + v["xpub"])')" +echo "cosigner B: $COSIGNER_B_KEY" + +DESC_NO_CKSUM="tr(musig(${COSIGNER_A_KEY},${COSIGNER_B_KEY})/<0;1>/*)" +CKSUM="$(core_cli getdescriptorinfo "$DESC_NO_CKSUM" \ + | "${PYTHON_CMD[@]}" -c 'import json,sys;print(json.load(sys.stdin)["checksum"])')" +DESC="${DESC_NO_CKSUM}#${CKSUM}" + +echo "== importdescriptors" +wallet_cli importdescriptors "$("${PYTHON_CMD[@]}" -c " +import json, sys +print(json.dumps([{'desc': sys.argv[1], 'active': True, 'timestamp': 'now'}])) +" "$DESC")" | "${PYTHON_CMD[@]}" -c ' +import json, sys +for r in json.load(sys.stdin): + assert r.get("success") is True, f"importdescriptors failed: {r!r}" +' + +ADDR="$(wallet_cli getnewaddress "" bech32m)" +echo "receive address: $ADDR" +case "$ADDR" in + bcrt1p*) ;; + *) echo "unexpected address format: $ADDR" >&2; exit 1 ;; +esac + +echo "== registerpolicy '$POLICY_NAME' (Core -> hwi register -> speculos)" +REG_OUT="$(wallet_cli registerpolicy "$POLICY_NAME")" +echo "$REG_OUT" +HMAC="$(echo "$REG_OUT" | "${PYTHON_CMD[@]}" -c 'import json,sys;print(json.load(sys.stdin)["hmac"])')" +echo "registered hmac: $HMAC" + +wallet_cli getwalletinfo | FP="$FP_A" HMAC="$HMAC" NAME="$POLICY_NAME" "${PYTHON_CMD[@]}" -c ' +import json, os, sys +w = json.load(sys.stdin) +hmacs = w.get("bip388", []) +assert hmacs, f"no bip388 hmacs in getwalletinfo: {w!r}" +match = next((h for h in hmacs + if h["name"] == os.environ["NAME"] + and h["fingerprint"] == os.environ["FP"]), None) +assert match is not None, f"no matching bip388 entry in {hmacs!r}" +assert match["hmac"] == os.environ["HMAC"], f"hmac mismatch: {match!r}" +' +echo "== OK: registered MuSig2 wallet policy via hwi register" + +echo "== walletdisplayaddress through registered policy" +DISPLAY_OUT="$(wallet_cli walletdisplayaddress "$ADDR")" +echo "$DISPLAY_OUT" +echo "$DISPLAY_OUT" | "${PYTHON_CMD[@]}" -c ' +import json, sys +addr = json.load(sys.stdin).get("address") +assert addr and addr.startswith(("bcrt1p", "tb1p")), addr +' + +echo "== creating miner wallet, maturing coins" +core_cli -named createwallet wallet_name=miner descriptors=true blank=false >/dev/null +MINER_ADDR="$(miner_cli getnewaddress "" bech32m)" +miner_cli generatetoaddress 101 "$MINER_ADDR" >/dev/null + +echo "== funding $WALLET_NAME with 1.0 BTC" +FUND_TXID="$(miner_cli -named sendtoaddress address="$ADDR" amount=1.0)" +echo "fund txid: $FUND_TXID" +miner_cli generatetoaddress 1 "$MINER_ADDR" >/dev/null + +BAL="$(wallet_cli getbalance)" +echo "$WALLET_NAME balance: $BAL" +"${PYTHON_CMD[@]}" -c "assert float('$BAL') >= 0.999, 'unexpected balance: $BAL'" + +DEST_ADDR="$(miner_cli getnewaddress "" bech32m)" + +echo "== send (single call: expect both MuSig2 rounds, complete=true)" +SEND_OUT="$(wallet_cli -named send \ + outputs="[{\"$DEST_ADDR\": 0.5}]" \ + fee_rate=5)" +echo "$SEND_OUT" +COMPLETE="$(echo "$SEND_OUT" | "${PYTHON_CMD[@]}" -c 'import json,sys;print(json.load(sys.stdin)["complete"])')" +[[ "$COMPLETE" == "True" ]] || { echo "send did not complete in one call" >&2; exit 1; } +SPEND_TXID="$(echo "$SEND_OUT" | "${PYTHON_CMD[@]}" -c 'import json,sys;print(json.load(sys.stdin)["txid"])')" +echo "spend txid: $SPEND_TXID" + +echo "== mining a confirmation and verifying" +miner_cli generatetoaddress 1 "$MINER_ADDR" >/dev/null +wallet_cli gettransaction "$SPEND_TXID" \ + | "${PYTHON_CMD[@]}" -c 'import json,sys;t=json.load(sys.stdin);assert t["confirmations"] >= 1, t;print("confirmations:", t["confirmations"])' + +echo "== OK: signed and broadcast a MuSig2 spend through Python HWI against app 2.4.1" diff --git a/test/run_tests.py b/test/run_tests.py index 80300db1f..812472533 100755 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -7,6 +7,7 @@ from test_base58 import TestBase58 from test_bech32 import TestSegwitAddress from test_bip32 import TestBIP32 +from test_commands import TestCommands from test_coldcard import coldcard_test_suite from test_descriptor import TestDescriptor from test_device import Bitcoind @@ -84,6 +85,7 @@ suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPSBT)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBase58)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBIP32)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestCommands)) if sys.platform.startswith("linux"): suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestUdevRulesInstaller)) success = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite).wasSuccessful() diff --git a/test/setup_environment.sh b/test/setup_environment.sh index 7d602a5f1..940c2eb30 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -71,6 +71,8 @@ BITBOX02_VERSION="firmware/v9.24.0" KEEPKEY_VERSION="v7.10.0" SPECULOS_VERSION="v0.25.10" # Last version supporting Python 3.9 (v0.25.11+ requires >=3.10) JADE_VERSION="1.0.36" +BITCOIND_REPO_URL="${BITCOIND_REPO_URL:-https://github.com/Sjors/bitcoin.git}" +BITCOIND_BRANCH="${BITCOIND_BRANCH:-2025/06/musig2-power}" # Keep COLDCARD_VERSION in sync with .github/actions/install-sim/action.yml COLDCARD_VERSION="2025-09-30T1238-v5.4.4" @@ -115,12 +117,15 @@ if [[ -n ${build_trezor_1} || -n ${build_trezor_t} ]]; then fi if [[ -n ${build_trezor_t} ]]; then - rustup update - rustup toolchain uninstall nightly - rustup toolchain install nightly - rustup default nightly - rustup component add rustfmt - rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu + # Pin the Rust nightly to the one trezor-firmware ${TREZOR_VERSION} expects + # (see its shell.nix: rust-bin.nightly."2025-04-15"). The rolling "nightly" + # channel has drifted and newer nightlies reject the firmware's + # reexport_test_harness_main attribute with error E0658. + TREZOR_RUST_NIGHTLY="nightly-2025-04-15" + rustup toolchain install ${TREZOR_RUST_NIGHTLY} + rustup default ${TREZOR_RUST_NIGHTLY} + rustup component add rustfmt --toolchain ${TREZOR_RUST_NIGHTLY} + rustup component add rust-src --toolchain ${TREZOR_RUST_NIGHTLY}-x86_64-unknown-linux-gnu # Build trezor t emulator. This is pretty fast, so rebuilding every time is ok # But there should be some caching that makes this faster git am ../../data/trezor-t-build.patch @@ -437,28 +442,15 @@ fi if [[ -n ${build_bitcoind} ]]; then # Clone bitcoind if it doesn't exist, or update it if it does - bitcoind_setup_needed=false if [ ! -d "bitcoin" ]; then - git clone https://github.com/bitcoin/bitcoin.git + git clone --branch "$BITCOIND_BRANCH" "$BITCOIND_REPO_URL" bitcoin cd bitcoin - bitcoind_setup_needed=true else cd bitcoin - git reset --hard origin/master - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull - bitcoind_setup_needed=true - fi + git remote set-url origin "$BITCOIND_REPO_URL" + git fetch origin "$BITCOIND_BRANCH" + git checkout -B "$BITCOIND_BRANCH" "origin/$BITCOIND_BRANCH" + git reset --hard "origin/$BITCOIND_BRANCH" fi # Build bitcoind. This is super slow, but it is cached so it runs fairly quickly. @@ -468,5 +460,5 @@ if [[ -n ${build_bitcoind} ]]; then # Do the build cmake -B build --toolchain depends/x86_64-pc-linux-gnu/toolchain.cmake -DBUILD_TESTS=OFF -DBUILD_BENCH=OFF - cmake --build build -j $(nproc) --target bitcoind + cmake --build build -j $(nproc) --target bitcoind bitcoin-cli fi diff --git a/test/test_commands.py b/test/test_commands.py new file mode 100644 index 000000000..d8fad7edb --- /dev/null +++ b/test/test_commands.py @@ -0,0 +1,100 @@ +#! /usr/bin/env python3 + +import unittest + +from hwilib._cli import get_parser +from hwilib.commands import displayaddress, signtx +from hwilib.common import BIP388Policy +from hwilib.errors import BadArgumentError +from hwilib.psbt import PSBT, PartiallySignedInput, PartiallySignedOutput + + +class _SignTxNoPolicyClient: + def sign_tx(self, psbt): + self.signed = psbt + return psbt + + +class _PolicyDisplayClient: + def display_bip388_policy_address(self, bip388_policy, index, change=False): + self.bip388_policy = bip388_policy + self.index = index + self.change = change + return "bcrt1policyaddress" + + +class TestCommands(unittest.TestCase): + def test_signtx_preserves_legacy_library_sign_tx_shape(self): + tx = PSBT() + tx.version = 2 + tx.tx_version = 2 + + psbt_in = PartiallySignedInput(tx.version) + psbt_in.prev_txid = bytes.fromhex("11" * 32) + psbt_in.prev_out = 0 + tx.inputs.append(psbt_in) + + psbt_out = PartiallySignedOutput(tx.version) + psbt_out.amount = 1000 + psbt_out.script = bytes.fromhex("51") + tx.outputs.append(psbt_out) + + psbt = tx.serialize() + client = _SignTxNoPolicyClient() + + result = signtx(client, psbt) + + self.assertEqual(result["psbt"], psbt) + self.assertFalse(result["signed"]) + self.assertEqual(client.signed.serialize(), psbt) + + def test_displayaddress_parser_accepts_policy_mode(self): + args = get_parser().parse_args([ + "--device-type", "ledger", + "displayaddress", + "--policy-name", "MuSigTest", + "--policy-desc", "tr(musig(@0,@1)/**)", + "--key", "[00000000/87h/1h/0h]tpubA", + "--key", "[00000000/87h/1h/0h]tpubB", + "--index", "7", + "--change", + ]) + + self.assertEqual(args.policy_name, "MuSigTest") + self.assertEqual(args.policy_desc, "tr(musig(@0,@1)/**)") + self.assertEqual(args.key, ["[00000000/87h/1h/0h]tpubA", "[00000000/87h/1h/0h]tpubB"]) + self.assertIsNone(args.hmac) + self.assertEqual(args.index, 7) + self.assertTrue(args.change) + + def test_displayaddress_policy_mode_calls_client(self): + client = _PolicyDisplayClient() + policy = BIP388Policy( + name="MuSigTest", + descriptor_template="tr(musig(@0,@1)/<0;1>/*)", + keys_info=["key_a", "key_b"], + ) + + result = displayaddress(client, bip388_policy=policy, index=12, change=True) + + self.assertEqual(result["address"], "bcrt1policyaddress") + self.assertEqual(result["index"], 12) + self.assertTrue(result["change"]) + self.assertEqual(client.bip388_policy, policy) + self.assertEqual(client.index, 12) + self.assertTrue(client.change) + + def test_displayaddress_policy_mode_conflicts_with_descriptor(self): + client = _PolicyDisplayClient() + policy = BIP388Policy( + name="MuSigTest", + descriptor_template="tr(@0/**)", + keys_info=["key_a"], + ) + + with self.assertRaisesRegex(BadArgumentError, "mutually exclusive"): + displayaddress(client, desc="wpkh([00000000/84h/1h/0h]tpub/0/*)", bip388_policy=policy, index=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ledger.py b/test/test_ledger.py index 6ab162e74..f86ec76a7 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -164,6 +164,54 @@ def test_getxpub(self): self.assertEqual(result['chaincode'], "1067f2a53975faf7ac265be505c1c50ef80a0dcbe1f53f50497c5618e8888dbd") self.assertEqual(result['pubkey'], "035879ca173a9c1b3f300ec587fb4cc6d54d618e30584e425c1b53b98828708f1d") +class TestLedgerBIP388Policy(DeviceTestCase): + def _policy_args(self): + key_a = self.do_command(self.dev_args + ['getxpub', 'm/87h/1h/0h'])['xpub'] + key_b = self.do_command(self.dev_args + ['getxpub', 'm/87h/1h/1h'])['xpub'] + cosigner_a = "[{}/87h/1h/0h]{}".format(self.emulator.fingerprint, key_a) + cosigner_b = "[{}/87h/1h/1h]{}".format(self.emulator.fingerprint, key_b) + return [ + '--name', 'MuSigTest', + '--desc', 'tr(musig(@0,@1)/**)', + '--key', cosigner_a, + '--key', cosigner_b, + ] + + def test_register_policy_hmac_roundtrip(self): + if self.emulator.legacy: + raise unittest.SkipTest("Legacy Ledger app does not support BIP388 policies") + + policy_args = self._policy_args() + first = self.do_command(self.dev_args + ['register'] + policy_args) + second = self.do_command(self.dev_args + ['register'] + policy_args) + + self.assertNotIn('error', first) + self.assertNotIn('code', first) + self.assertEqual(len(first['hmac']), 64) + self.assertEqual(second['hmac'], first['hmac']) + + def test_display_policy_address(self): + if self.emulator.legacy: + raise unittest.SkipTest("Legacy Ledger app does not support BIP388 policies") + + policy_args = self._policy_args() + registered = self.do_command(self.dev_args + ['register'] + policy_args) + result = self.do_command(self.dev_args + [ + 'displayaddress', + '--policy-name', 'MuSigTest', + '--policy-desc', 'tr(musig(@0,@1)/**)', + '--key', policy_args[5], + '--key', policy_args[7], + '--hmac', registered['hmac'], + '--index', '0', + ]) + + self.assertNotIn('error', result) + self.assertNotIn('code', result) + self.assertIn('address', result) + self.assertEqual(result['index'], 0) + self.assertFalse(result['change']) + def ledger_test_suite(emulator, bitcoind, interface, legacy=False): dev_emulator = LedgerEmulator(emulator, legacy) @@ -182,6 +230,7 @@ def ledger_test_suite(emulator, bitcoind, interface, legacy=False): suite = unittest.TestSuite() suite.addTest(DeviceTestCase.parameterize(TestLedgerDisabledCommands, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestLedgerGetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestLedgerBIP388Policy, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type=dev_emulator.type)) suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) diff --git a/test/test_psbt.py b/test/test_psbt.py index 3a585b060..face4631a 100755 --- a/test/test_psbt.py +++ b/test/test_psbt.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -from hwilib.psbt import PSBT +from hwilib.psbt import PSBT, PartiallySignedInput, PartiallySignedOutput from hwilib.errors import PSBTSerializationError import json import os @@ -28,5 +28,29 @@ def test_valid_psbt(self): serd = psbt.serialize() self.assertEqual(valid, serd) + def test_v2_tx_version_and_fallback_locktime_roundtrip(self): + psbt = PSBT() + psbt.version = 2 + psbt.tx_version = 2 + psbt.fallback_locktime = 123456 + + psbt_in = PartiallySignedInput(psbt.version) + psbt_in.prev_txid = bytes.fromhex("11" * 32) + psbt_in.prev_out = 0 + psbt.inputs.append(psbt_in) + + psbt_out = PartiallySignedOutput(psbt.version) + psbt_out.amount = 1000 + psbt_out.script = bytes.fromhex("51") + psbt.outputs.append(psbt_out) + + serialized = psbt.serialize() + parsed = PSBT() + parsed.deserialize(serialized) + + self.assertEqual(parsed.tx_version, 2) + self.assertEqual(parsed.fallback_locktime, 123456) + self.assertEqual(parsed.serialize(), serialized) + if __name__ == "__main__": unittest.main()