Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/build-bitcoind/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/test-device/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions docs/examples/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ Example Usage

bitcoin-core-usage
examples
musig2-ledger
walkthrough/walkthrough
100 changes: 100 additions & 0 deletions docs/examples/musig2-ledger.rst
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 43 additions & 3 deletions hwilib/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
getdescriptors,
prompt_pin,
toggle_passphrase,
register,
restore_device,
send_pin,
setup_device,
Expand All @@ -22,6 +23,7 @@
)
from .common import (
AddressType,
BIP388Policy,
Chain,
)
from .errors import (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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='')
Expand Down
64 changes: 58 additions & 6 deletions hwilib/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,6 +51,7 @@
from .devices import __all__ as all_devs
from .common import (
AddressType,
BIP388Policy,
Chain,
)
from .hwwclient import HardwareWalletClient
Expand Down Expand Up @@ -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.

Expand All @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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": <base58 or bech32 address string>}``.
: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:
Expand Down Expand Up @@ -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": <hex string>}``.
: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]:
"""
Expand Down
18 changes: 16 additions & 2 deletions hwilib/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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:
"""
Expand Down
Loading
Loading