This commit is contained in:
scgbckbone 2025-06-26 15:48:26 +02:00
parent d4e8dfb9d5
commit 32d28dea01
7 changed files with 52 additions and 170 deletions

View File

@ -165,6 +165,10 @@ class KeyDerivationInfo:
def __hash__(self):
return hash(self.indexes)
@staticmethod
def not_hardened(x):
assert (b"'" not in x) and (b"h" not in x), "Cannot use hardened sub derivation path"
@classmethod
def parse(cls, s):
err = "Malformed key derivation"
@ -172,26 +176,31 @@ class KeyDerivationInfo:
idxs = []
while True:
got, char = read_until(s, b"<,)/")
if char == b"<":
assert multi_i is None, "too many multipaths"
ext_num, char = read_until(s, b";")
assert char, err
cls.not_hardened(ext_num)
int_num, char = read_until(s, b">")
assert char, err
cls.not_hardened(int_num)
assert int_num != ext_num # cannot be the same
multi_i = len(idxs)
idxs.append((int(ext_num.decode()), int(int_num.decode())))
else:
# char in "/),"
if got == b"*":
# every derivation has to end with wildcard (only ranged keys allowed)
idxs.append(WILDCARD)
break
elif got:
cls.not_hardened(got)
idxs.append(int(got.decode()))
elif got == b"*":
# every derivation has to end with wildcard (only ranged keys allowed)
idxs.append(WILDCARD)
break
elif char == b"/" and got:
assert (b"'" not in got) and (b"h" not in got), "Cannot use hardened sub derivation path"
idxs.append(int(got.decode()))
# comma and parenthesis not allowed in subderivation, marker of the end
if char in b",)": break
assert idxs[-1] == WILDCARD, "All keys must be ranged"
if idxs == [0, WILDCARD]:

View File

@ -407,23 +407,21 @@ class Descriptor:
tree = self.tapscript.to_string(external, internal)
desc += tree
desc = desc + ")"
if checksum:
desc = append_checksum(desc)
return desc
res = desc + ")"
if self.miniscript is not None:
res = self.miniscript.to_string(external, internal)
if self.addr_fmt in [AF_P2WSH, AF_P2WSH_P2SH]:
res = "wsh(%s)" % res
else:
if self.addr_fmt == AF_P2WPKH:
res = "wpkh(%s)" % self.key.to_string(external, internal)
if self.miniscript is not None:
res = self.miniscript.to_string(external, internal)
if self.addr_fmt in [AF_P2WSH, AF_P2WSH_P2SH]:
res = "wsh(%s)" % res
else:
res = "pkh(%s)" % self.key.to_string(external, internal)
if self.addr_fmt in [AF_P2WPKH, AF_P2WPKH_P2SH]:
res = "wpkh(%s)" % self.key.to_string(external, internal)
else:
res = "pkh(%s)" % self.key.to_string(external, internal)
if self.is_legacy_sh:
res = "sh(%s)" % res
if self.is_legacy_sh:
res = "sh(%s)" % res
if checksum:
res = append_checksum(res)

View File

@ -447,11 +447,10 @@ class MiniScriptWallet(BaseStorageWallet):
for msc in cls.iter_wallets():
kp = msc.kt_my_keypair(ri)
for k in msc.keys:
kk = Key.from_string(k)
if kk.origin.cc_fp == my_xfp:
for k in msc.to_descriptor().keys:
if k.origin.cc_fp == my_xfp:
continue
kk = kk.derive(KT_RXPUBKEY_DERIV).derive(ri)
kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri)
his_pubkey = kk.node.pubkey()
# if implied session key decodes the checksum, it is right
ses_key, body = decode_step1(kp, his_pubkey, payload[4:])

View File

@ -179,14 +179,16 @@ class MultisigWallet(BaseStorageWallet):
return which
@property
def chain(self):
return chains.current_chain()
def serialize(self):
# return a JSON-able object
opts = dict()
if self.addr_fmt != AF_P2SH:
opts['ft'] = self.addr_fmt
if self.chain_type != 'BTC':
opts['ch'] = self.chain_type
# Data compression: most legs will all use same derivation.
# put a int(0) in place and set option 'pp' to be derivation
@ -684,7 +686,7 @@ class MultisigWallet(BaseStorageWallet):
M, N = descriptor.miniscript.m_n()
for key in descriptor.miniscript.keys:
assert key.derivation.is_external, "Invalid subderivation path - only 0/* or <0;1>/* allowed"
assert key.derivation.indexes == ((0,1), "*"), "Invalid subderivation path - only 0/* or <0;1>/* allowed"
xfp = key.origin.cc_fp
deriv = key.origin.str_derivation()
xpub = key.extended_public_key()
@ -939,7 +941,7 @@ class MultisigWallet(BaseStorageWallet):
name = 'PSBT-%d-of-%d' % (M, N)
# this will always create sortedmulti multisig (BIP-67)
# because BIP-174 came years after wide spread acceptance of BIP-67 policy
ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy
ms = cls(name, (M, N), xpubs, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy
# may just keep in-memory version, no approval required, if we are
# trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet
@ -964,7 +966,7 @@ class MultisigWallet(BaseStorageWallet):
# cleanup and normalize xpub
tmp = []
is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0),
self.chain_type, 0, self.disable_checks)
chains.current_chain().ctype, 0, self.disable_checks)
tmp.append(item)
(_, deriv, xpub_reserialized) = tmp[0]
assert deriv # because given as arg
@ -1091,7 +1093,7 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=
vmsg = ('Policy: {M} of {N}\n'
'Blockchain: {ctype}\n'
'Addresses: {at}\n\n')
vmsg = vmsg.format(M=self.M, N=self.N, ctype=self.chain_type,
vmsg = vmsg.format(M=self.M, N=self.N, ctype=chains.current_chain().ctype,
at=self.render_addr_fmt(self.addr_fmt))
msg.write(vmsg)

View File

@ -2838,11 +2838,11 @@ class psbtObject(psbtProxy):
for xpk, lhs_pths in inp.taproot_subpaths.items():
if not lhs_pths[0]:
# no leaf hashes - internal key
if self.active_miniscript:
k = Key.from_string(self.active_miniscript.key)
if k.is_provably_unspendable:
continue
if inp.taproot_key_sig:
# already signed
continue
if self.active_miniscript.to_descriptor().key.is_provably_unspendable:
# no way to sign with unspend
continue
else:
signed = {xonly for (xonly, lhs) in inp.taproot_script_sigs.keys()}

View File

@ -637,7 +637,7 @@ async def kt_send_psbt(psbt, psbt_len):
elif psbt.active_miniscript:
ms = psbt.active_miniscript
all_xfps = {x for x,*p in psbt.active_miniscript.xfp_paths(skip_unspend_ik=True)}
all_xfps = {x for x,*p in psbt.active_miniscript.to_descriptor().xfp_paths(skip_unspend_ik=True)}
else:
assert False

View File

@ -2424,131 +2424,6 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke
assert bitcoind_addrs[idx] == address
@pytest.fixture
def bitcoind_multisig(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, load_export, pick_menu_item, goto_home,
cap_menu, microsd_path, use_regtest, press_select):
def doit(M, N, script_type, cc_account=0, funded=True):
use_regtest()
bitcoind_signers = [
bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False,
passphrase=None, avoid_reuse=False, descriptors=True)
for i in range(N - 1)
]
for signer in bitcoind_signers:
signer.keypoolrefill(10)
# watch only wallet where multisig descriptor will be imported
ms = bitcoind.create_wallet(
wallet_name=f"watch_only_{script_type}_{M}of{N}", disable_private_keys=True,
blank=True, passphrase=None, avoid_reuse=False, descriptors=True
)
goto_home()
pick_menu_item('Settings')
pick_menu_item('Multisig Wallets')
pick_menu_item('Export XPUB')
time.sleep(0.5)
title, story = cap_story()
assert "extended public keys (XPUB) you would need to join a multisig wallet" in story
press_select()
need_keypress(str(cc_account)) # account
press_select()
xpub_obj = load_export("sd", label="Multisig XPUB", is_json=True, sig_check=False)
template = xpub_obj[script_type +"_desc"]
# get keys from bitcoind signers
bitcoind_signers_xpubs = []
for signer in bitcoind_signers:
target_desc = ""
bitcoind_descriptors = signer.listdescriptors()["descriptors"]
for desc in bitcoind_descriptors:
if desc["desc"].startswith("pkh(") and desc["internal"] is False:
target_desc = desc["desc"]
core_desc, checksum = target_desc.split("#")
# remove pkh(....)
core_key = core_desc[4:-1]
bitcoind_signers_xpubs.append(core_key)
desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs))
import pdb;pdb.set_trace()
if script_type == 'p2wsh':
name = f"core{M}of{N}_native.txt"
elif script_type == "p2sh_p2wsh":
name = f"core{M}of{N}_wrapped.txt"
else:
name = f"core{M}of{N}_legacy.txt"
with open(microsd_path(name), "w") as f:
f.write(desc + "\n")
goto_home()
pick_menu_item('Settings')
pick_menu_item('Multisig Wallets')
pick_menu_item('Import from File')
time.sleep(0.3)
_, story = cap_story()
if "Press (1) to import multisig wallet file from SD Card" in story:
# in case Vdisk is enabled
need_keypress("1")
time.sleep(0.5)
pick_menu_item(name)
_, story = cap_story()
assert "Create new multisig wallet?" in story
assert name.split(".")[0] in story
assert f"{M} of {N}" in story
if M == N:
assert f"All {N} co-signers must approve spends" in story
else:
assert f"{M} signatures, from {N} possible" in story
if script_type == "p2wsh":
assert "P2WSH" in story
elif script_type == "p2sh":
assert "P2SH" in story
else:
assert "P2SH-P2WSH" in story
assert "Derivation:\n Varies (2)" in story
press_select() # approve multisig import
goto_home()
pick_menu_item('Settings')
pick_menu_item('Multisig Wallets')
menu = cap_menu()
pick_menu_item(menu[0]) # pick imported descriptor multisig wallet
pick_menu_item("Descriptors")
pick_menu_item("Bitcoin Core")
text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, sig_check=False)
text = text.replace("importdescriptors ", "").strip()
# remove junk
r1 = text.find("[")
r2 = text.find("]", -1, 0)
text = text[r1: r2]
core_desc_object = json.loads(text)
# import descriptors to watch only wallet
res = ms.importdescriptors(core_desc_object)
assert res[0]["success"]
assert res[1]["success"]
if funded:
if script_type == "p2wsh":
addr_type = "bech32"
elif script_type == "p2tr":
addr_type = "bech32m"
elif script_type == "p2sh":
addr_type = "legacy"
else:
addr_type = "p2sh-segwit"
addr = ms.getnewaddress("", addr_type)
if script_type == "p2wsh":
sw = "bcrt1q"
elif script_type == "p2tr":
sw = "bcrt1p"
else:
sw = "2"
assert addr.startswith(sw)
# get some coins and fund above multisig address
bitcoind.supply_wallet.sendtoaddress(addr, 49)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above
return ms, bitcoind_signers
return doit
@pytest.mark.bitcoind
def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, need_keypress,
pick_menu_item, cap_story, load_export, microsd_path, cap_menu, try_sign,
@ -2851,10 +2726,10 @@ def test_finalization(m_n, script, desc, use_regtest, clear_ms, bitcoind_multisi
@pytest.mark.bitcoind
@pytest.mark.parametrize("m_n", [(15,15)])
@pytest.mark.parametrize("script", ["p2wsh"])
@pytest.mark.parametrize("m_n", [(2,3), (3,5), (15,15)])
@pytest.mark.parametrize("script", ["p2wsh", "p2sh-p2wsh", "p2sh"])
@pytest.mark.parametrize("sighash", list(SIGHASH_MAP.keys()))
@pytest.mark.parametrize('desc', ["sortedmulti"])
@pytest.mark.parametrize('desc', ["multi", "sortedmulti"])
def test_bitcoind_MofN_tutorial(m_n, script, clear_ms, goto_home, need_keypress, pick_menu_item,
sighash, cap_menu, cap_story, microsd_path, use_regtest, bitcoind,
microsd_wipe, settings_set, is_q1, try_sign, press_select,
@ -2872,8 +2747,7 @@ def test_bitcoind_MofN_tutorial(m_n, script, clear_ms, goto_home, need_keypress,
microsd_wipe()
# actual bitcoind watch-only creation + COLDCARD enroll
bitcoind_watch_only, bitcoind_signers = bitcoind_multisig(M, N, script, ms_script=desc,
keypool_size=30)
bitcoind_watch_only, bitcoind_signers = bitcoind_multisig(M, N, script, ms_script=desc, keypool_size=30)
dest_addr = bitcoind_watch_only.getnewaddress("", addr_type)
# create funded PSBT
@ -3058,11 +2932,11 @@ def test_bitcoind_MofN_tutorial(m_n, script, clear_ms, goto_home, need_keypress,
("Wrong checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl7"),
("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"),
("All keys must be ranged", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#9h02aqg5"),
("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"),
("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"),
# ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"),
("xpub xfp wrong 0F056943", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"),
("wrong pubkey", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"),
("xpub depth", "wsh(sortedmulti(2,[0f056943/0h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"),
("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"),
("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"),
("Cannot use hardened sub derivation path", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0'/*))#3w6hpha3"),
("M must be <= N", "wsh(sortedmulti(3,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#uueddtsy"),
])