Skip to content

Taproot multisig #4159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions core/.changelog.d/4159.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Sending to and spending Taproot Multisig
13 changes: 10 additions & 3 deletions core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip340.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,10 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_3(mod_trezorcrypto_bip340_verify_obj,
/// def tweak_public_key(
/// public_key: bytes,
/// root_hash: bytes | None = None,
/// ) -> bytes:
/// ) -> tuple[int, bytes]:
/// """
/// Tweaks the public key with the specified root_hash.
/// First element of tuple is the parity, second is the tweaked public key.
/// """
STATIC mp_obj_t mod_trezorcrypto_bip340_tweak_public_key(size_t n_args,
const mp_obj_t *args) {
Expand All @@ -188,13 +189,19 @@ STATIC mp_obj_t mod_trezorcrypto_bip340_tweak_public_key(size_t n_args,

vstr_t tpk = {0};
vstr_init_len(&tpk, 32);
int parity = 0;
int ret = zkp_bip340_tweak_public_key((const uint8_t *)pk.buf, rh_ptr,
(uint8_t *)tpk.buf);
(uint8_t *)tpk.buf, &parity);
if (ret != 0) {
vstr_clear(&tpk);
mp_raise_ValueError("Failed to tweak public key");
}
return mp_obj_new_str_from_vstr(&mp_type_bytes, &tpk);

mp_obj_t result[2];
result[0] = mp_obj_new_int(parity);
result[1] = mp_obj_new_str_from_vstr(&mp_type_bytes, &tpk);

return mp_obj_new_tuple(2, result);
}

STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(
Expand Down
3 changes: 2 additions & 1 deletion core/mocks/generated/trezorcrypto/bip340.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ def verify(public_key: bytes, signature: bytes, digest: bytes) -> bool:
def tweak_public_key(
public_key: bytes,
root_hash: bytes | None = None,
) -> bytes:
) -> tuple[int, bytes]:
"""
Tweaks the public key with the specified root_hash.
First element of tuple is the parity, second is the tweaked public key.
"""


Expand Down
22 changes: 19 additions & 3 deletions core/src/apps/bitcoin/addresses.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

from apps.common import address_type

from .common import ecdsa_hash_pubkey, encode_bech32_address
from .common import (
ecdsa_hash_pubkey,
encode_bech32_address,
p2tr_multisig_tweaked_pubkey,
)
from .multisig import multisig_get_dummy_pubkey
from .scripts import output_script_native_segwit, write_output_script_multisig

if TYPE_CHECKING:
Expand Down Expand Up @@ -69,7 +74,11 @@ def get_address(
raise ProcessError("Taproot not enabled on this coin")

if multisig is not None:
raise ProcessError("Multisig not supported for taproot")
pubkeys = multisig_get_pubkeys(multisig)
dummy_pubkey = multisig_get_dummy_pubkey(multisig)
return _address_multisig_p2tr(
pubkeys, dummy_pubkey, multisig.m, coin.bech32_prefix
)

return _address_p2tr(node_public_key, coin)

Expand Down Expand Up @@ -116,6 +125,13 @@ def _address_multisig_p2wsh(pubkeys: list[bytes], m: int, hrp: str) -> str:
return _address_p2wsh(witness_script_h.get_digest(), hrp)


def _address_multisig_p2tr(
pubkeys: list[bytes], dummy_pubkey: bytes, m: int, hrp: str
) -> str:
_, output_pubkey = p2tr_multisig_tweaked_pubkey(pubkeys, dummy_pubkey, m)
return encode_bech32_address(hrp, 1, output_pubkey)


def address_pkh(pubkey: bytes, coin: CoinInfo) -> str:
s = address_type.tobytes(coin.address_type) + coin.script_hash(pubkey).digest()
return base58.encode_check(bytes(s), coin.b58_hash)
Expand Down Expand Up @@ -153,7 +169,7 @@ def _address_p2tr(pubkey: bytes, coin: CoinInfo) -> str:
from trezor.crypto.curve import bip340

assert coin.bech32_prefix is not None
output_pubkey = bip340.tweak_public_key(pubkey[1:])
_, output_pubkey = bip340.tweak_public_key(pubkey[1:])
return encode_bech32_address(coin.bech32_prefix, 1, output_pubkey)


Expand Down
32 changes: 30 additions & 2 deletions core/src/apps/bitcoin/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def from_int(cls, sighash_type: int) -> "SigHashType":
# Bitcoin opcodes
OP_0 = const(0x00)
OP_1 = const(0x51)
OP_CHECKSIG = const(0xAC)
OP_CHECKSIGADD = const(0xBA)
OP_NUMEQUAL = const(0x9C)

# supported witness versions for bech32 addresses
_BECH32_WITVERS = (0, 1)
Expand All @@ -68,11 +71,13 @@ def from_int(cls, sighash_type: int) -> "SigHashType":
InputScriptType.SPENDMULTISIG,
InputScriptType.SPENDP2SHWITNESS,
InputScriptType.SPENDWITNESS,
InputScriptType.SPENDTAPROOT,
)
MULTISIG_OUTPUT_SCRIPT_TYPES = (
OutputScriptType.PAYTOMULTISIG,
OutputScriptType.PAYTOP2SHWITNESS,
OutputScriptType.PAYTOWITNESS,
OutputScriptType.PAYTOTAPROOT,
)

CHANGE_OUTPUT_TO_INPUT_SCRIPT_TYPES: dict[OutputScriptType, InputScriptType] = {
Expand Down Expand Up @@ -103,6 +108,8 @@ def from_int(cls, sighash_type: int) -> "SigHashType":
InputScriptType.SPENDMULTISIG,
)

LEAF_VERSION = const(0xC0)


def ecdsa_sign(node: bip32.HDNode, digest: bytes) -> bytes:
from trezor.crypto import der
Expand All @@ -113,12 +120,33 @@ def ecdsa_sign(node: bip32.HDNode, digest: bytes) -> bytes:
return sigder


def bip340_sign(node: bip32.HDNode, digest: bytes) -> bytes:
def bip340_sign(node: bip32.HDNode, digest: bytes, tweak: bool = True) -> bytes:
internal_private_key = node.private_key()
output_private_key = bip340.tweak_secret_key(internal_private_key)
output_private_key = (
bip340.tweak_secret_key(internal_private_key) if tweak else internal_private_key
)
return bip340.sign(output_private_key, digest)


def p2tr_multisig_leaf_hash(pubkeys: list[bytes], m: int) -> bytes:
from .scripts import write_output_script_multisig_taproot

hash_writer = tagged_hashwriter(b"TapLeaf")
hash_writer.append(LEAF_VERSION)

write_output_script_multisig_taproot(hash_writer, pubkeys, m)

return hash_writer.get_digest()


def p2tr_multisig_tweaked_pubkey(
pubkeys: list[bytes], internal_pubkey: bytes, m: int
) -> tuple[int, bytes]:
leaf_hash = p2tr_multisig_leaf_hash(pubkeys, m)

return bip340.tweak_public_key(internal_pubkey[1:], leaf_hash)


def ecdsa_hash_pubkey(pubkey: bytes, coin: CoinInfo) -> bytes:
from trezor.utils import ensure

Expand Down
58 changes: 50 additions & 8 deletions core/src/apps/bitcoin/multisig.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,10 @@ def validate_multisig(multisig: MultisigRedeemScriptType) -> None:

def multisig_pubkey_index(multisig: MultisigRedeemScriptType, pubkey: bytes) -> int:
validate_multisig(multisig)
if multisig.nodes:
for i, hd_node in enumerate(multisig.nodes):
if multisig_get_pubkey(hd_node, multisig.address_n) == pubkey:
return i
else:
for i, hd in enumerate(multisig.pubkeys):
if multisig_get_pubkey(hd.node, hd.address_n) == pubkey:
return i
pubkeys = multisig_get_pubkeys(multisig)
for i, derived_pubkey in enumerate(pubkeys):
if derived_pubkey == pubkey:
return i
raise DataError("Pubkey not found in multisig script")


Expand All @@ -85,6 +81,52 @@ def multisig_get_pubkey(n: HDNodeType, p: paths.Bip32Path) -> bytes:
return node.public_key()


def compute_taproot_dummy_chaincode(multisig: MultisigRedeemScriptType) -> bytes:
from trezor.crypto.hashlib import sha256
from trezor.utils import HashWriter

from .writers import write_bytes_fixed

if len(multisig.address_n) != 2:
raise DataError("Taproot multisig must use xpub derivation depth of 2")

if multisig.nodes:
pubkeys = [hd.public_key for hd in multisig.nodes]
else:
pubkeys = [hd.public_key for hd in multisig.pubkeys]
pubkeys.sort()
h = HashWriter(sha256())
prev = None
for pubkey in pubkeys:
if prev == pubkey:
continue
prev = pubkey
write_bytes_fixed(h, pubkey, 33)

return h.get_digest()


def multisig_get_dummy_pubkey(multisig: MultisigRedeemScriptType) -> bytes:
from trezor.crypto import bip32

# The following encodes this xpub into an HDNode. It is the NUMS point suggested
# in BIP341, with a chaincode derived from the sha256 of the sorted public keys with duplicates removed.
# Deriving a pubkey from this node results in a provably unspendable pubkey.
# https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304
# xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6QgnecKFpJFPpdzxKrwoaZoV44qAJewsc4kX9vGaCaBExuvJH57
node = bip32.HDNode(
depth=0,
fingerprint=2084970077,
child_num=0,
chain_code=compute_taproot_dummy_chaincode(multisig),
public_key=b"\x02P\x92\x9bt\xc1\xa0IT\xb7\x8bK`5\xe9z^\x07\x8aZ\x0f(\xec\x96\xd5G\xbf\xee\x9a\xce\x80:\xc0",
)

for i in multisig.address_n:
node.derive(i, True)
return node.public_key()
Comment on lines +125 to +127
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I don't understand: The dummy pubkey is derived using multisig.address_n. Suppose we have two wallets. The first one uses a node HD1 and the other a node HD2. Suppose we have a multisig address created from public keys HD1/0/0 and HD1/0/1. Since the wallets use different paths to derive the dummy pubkey, they will generate different addresses. Consequently, if the first wallet creates a transaction with the multisig address as an output, the second wallet will create a valid witness with a probability of 1/2, because the parity of the tweaked dummy key is used in the witness. Is this correct?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But address_n must be the same for the entire MultisigRedeemScriptType, so how can the address be created by HD1/0/0 and HD1/0/1? That would imply an address_n of [0,0] for the first key and [0,1] for the second. Am I misunderstanding?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was under the impression that you could specify address_n for each pubkey separately in multisig.pubkeys[*].address_n.

return [multisig_get_pubkey(hd.node, hd.address_n) for hd in multisig.pubkeys]

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see you can have pubkeys or nodes, and pubkeys have their own address_n individually. I haven't tested this with that. I suppose we can disable using pubkeys list for taproot multisig?
Is pubkeys list legacy or is it still widely used?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, I'm going to do a quick research to see what internal keys other wallets that support Taproot multisig use. If we want other wallets to be interoperable with trezor, we should follow the same approach.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it makes a difference in terms of security and privacy for the first 3 options.
Only the chain code and public key are used to derive the actual public key that will be used in the script though, so the last option would introduce malleability. For example, two xpubs where one lies about the depth would produce the same redeem script but would result in a different chaincode for the dummy xpub.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I didn't realize but if using just the 33 bytes compressed public key like I have here it does make it compatible with Liana, if Liana has their xpubs sorted, no duplicates, and max depth of 2.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can proceed with a BIP and mailing list post if this is acceptable.

Thanks. Unless @onvej-sl has any objection to the proposed method, I encourage you to go ahead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no objections.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your patience. I have created a BIP draft specifying this behavior. Once it gets assigned a number I can rebase this and hopefully we can see it merged 🤞

bitcoin/bips#1746



def multisig_get_pubkeys(multisig: MultisigRedeemScriptType) -> list[bytes]:
validate_multisig(multisig)
if multisig.nodes:
Expand Down
90 changes: 88 additions & 2 deletions core/src/apps/bitcoin/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@
from apps.common.writers import write_compact_size

from . import common
from .common import SigHashType
from .multisig import multisig_get_pubkeys, multisig_pubkey_index
from .common import (
LEAF_VERSION,
OP_CHECKSIG,
OP_CHECKSIGADD,
OP_NUMEQUAL,
SigHashType,
p2tr_multisig_tweaked_pubkey,
)
from .multisig import (
multisig_get_dummy_pubkey,
multisig_get_pubkeys,
multisig_pubkey_index,
)
from .readers import read_memoryview_prefixed, read_op_push
from .writers import (
write_bytes_fixed,
Expand Down Expand Up @@ -591,6 +602,81 @@ def parse_output_script_multisig(script: bytes) -> tuple[list[memoryview], int]:
return public_keys, threshold


# Taproot Multisig
# ===


def write_witness_multisig_taproot(
w: Writer,
multisig: MultisigRedeemScriptType,
signature: bytes,
signature_index: int,
sighash_type: SigHashType,
) -> None:
from .multisig import multisig_get_pubkey_count

# get other signatures, stretch with empty bytes to the number of the pubkeys
signatures = multisig.signatures + [b""] * (
multisig_get_pubkey_count(multisig) - len(multisig.signatures)
)

# fill in our signature
if signatures[signature_index]:
raise DataError("Invalid multisig parameters")
signatures[signature_index] = signature

# signatures + redeem script + control block
num_of_witness_items = len(signatures) + 1 + 1
write_compact_size(w, num_of_witness_items)

for s in reversed(signatures):
if s:
write_signature_prefixed(w, s, sighash_type) # size of the witness included
else:
w.append(0x00)

# redeem script
pubkeys = multisig_get_pubkeys(multisig)
write_output_script_multisig_taproot(w, pubkeys, multisig.m)

# control block
dummy_pubkey = multisig_get_dummy_pubkey(multisig)
write_compact_size(w, len(dummy_pubkey[1:]) + 1)
parity, _ = p2tr_multisig_tweaked_pubkey(pubkeys, dummy_pubkey, multisig.m)
w.append(LEAF_VERSION + parity)
w.extend(dummy_pubkey[1:])


def write_output_script_multisig_taproot(
w: Writer,
pubkeys: Sequence[bytes | memoryview],
m: int,
) -> None:
n = len(pubkeys)
if n < 1 or n > 15 or m < 1 or m > 15 or m > n:
raise DataError("Invalid multisig parameters")
for pubkey in pubkeys:
if len(pubkey) != 33:
raise DataError("Invalid multisig parameters")

write_compact_size(w, output_script_multisig_taproot_length(pubkeys))

iterator = iter(pubkeys)
append_pubkey(w, next(iterator)[1:])
w.append(OP_CHECKSIG)
for p in iterator:
append_pubkey(w, p[1:])
w.append(OP_CHECKSIGADD)
w.append(0x50 + m) # numbers 1 to 16 are pushed as 0x50 + value
w.append(OP_NUMEQUAL)


def output_script_multisig_taproot_length(pubkeys: Sequence[bytes | memoryview]) -> int:
return (
len(pubkeys) * (1 + 32 + 1) + 1 + 1
) # see write_output_script_multisig_taproot


# OP_RETURN
# ===

Expand Down
Loading