Reject witness-only UTXO for legacy inputs; Suppress fee for unverified witness UTXOs;normalize legacy inputs to proper utxo

This commit is contained in:
scgbckbone 2026-05-26 15:41:08 +02:00 committed by doc-hex
parent d5aba396a6
commit 59eb529a20
6 changed files with 224 additions and 65 deletions

View File

@ -9,6 +9,9 @@ This lists the new changes that have not yet been published in a normal release.
- Enhancement: WIF Store export watch-only descriptor
- Enhancement: WIF Store address detection without the need for PSBT_IN_BIP32_DERIVATION (Electrum support)
- Enhancement: Improve USB length validation
- Bugfix: Fixes legacy input amount spoofing by rejecting witness-utxo-only PSBT inputs when Coldcard is expected to sign a non-segwit input.
When both UTXO fields are present the full non_witness_utxo is now preferred for amount/script lookup. Thanks, @Damir
- Bugfix: Emit warning and do not calculate fee for legacy UTXOs with only witness utxo
- Bugfix: Disable Virtual Disk and NFC before activating HSM
- Bugfix: Custom address default menu position wrong
- Bugfix: Delta Mode Trick PIN was never restored from backup

View File

@ -720,49 +720,58 @@ class psbtInputProxy(psbtProxy):
fd = self.fd
old_pos = fd.tell()
if self.witness_utxo:
# Going forward? Just what we will witness; no other junk
# - prefer this format, altho does that imply segwit txn must be generated?
# - I don't know why we wouldn't always use this
# - once we use this partial utxo data, we must create witness data out
if self.utxo:
# skip over all the parts of the txn we don't care about, without
# fully parsing it... pull out a single TXO
fd.seek(self.utxo[0])
_, marker, flags = unpack("<iBB", fd.read(6))
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
# rewind back over marker+flags
fd.seek(-2, 1)
# How many ins? We accept zero here because utxo's inputs might have been
# trimmed to save space, and we have test cases like that.
num_in = deser_compact_size(fd)
_skip_n_objs(fd, num_in, 'CTxIn')
num_out = deser_compact_size(fd)
assert idx < num_out, "not enuf outs"
_skip_n_objs(fd, idx, 'CTxOut')
fd.seek(self.witness_utxo[0])
utxo = CTxOut()
utxo.deserialize(fd)
# ... followed by more outs, and maybe witness data, but we don't care ...
fd.seek(old_pos)
return utxo
assert self.utxo, 'no utxo'
# skip over all the parts of the txn we don't care about, without
# fully parsing it... pull out a single TXO
fd.seek(self.utxo[0])
_, marker, flags = unpack("<iBB", fd.read(6))
wit_format = (marker == 0 and flags != 0x0)
if not wit_format:
# rewind back over marker+flags
fd.seek(-2, 1)
# How many ins? We accept zero here because utxo's inputs might have been
# trimmed to save space, and we have test cases like that.
num_in = deser_compact_size(fd)
_skip_n_objs(fd, num_in, 'CTxIn')
num_out = deser_compact_size(fd)
assert idx < num_out, "not enuf outs"
_skip_n_objs(fd, idx, 'CTxOut')
assert self.witness_utxo, 'no utxo'
fd.seek(self.witness_utxo[0])
utxo = CTxOut()
utxo.deserialize(fd)
# ... followed by more outs, and maybe witness data, but we don't care ...
fd.seek(old_pos)
return utxo
def witness_utxo_is_provably_segwit(self, utxo):
af, addr_or_pubkey, addr_is_segwit = utxo.get_address()
if addr_is_segwit:
return True
if af != AF_P2SH or not self.redeem_script:
return False
redeem_script = self.get(self.redeem_script)
return redeem_script[0] == 0 and \
((len(redeem_script) == 22 and redeem_script[1] == 20) or
(len(redeem_script) == 34 and redeem_script[1] == 32)) and \
hash160(redeem_script) == addr_or_pubkey
def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt, cosign_xfp=None):
# See what it takes to sign this particular input
# - type of script
@ -1831,6 +1840,7 @@ class psbtObject(psbtProxy):
# hashes match, and what values are we getting?
# Important: parse incoming UTXO to build total input value
foreign = []
unverified_witness_utxo = []
total_in = 0
from_wif_store = []
prevouts = set()
@ -1860,12 +1870,18 @@ class psbtObject(psbtProxy):
assert utxo.nValue >= 0, "negative input value: i%d" % i
total_in += utxo.nValue
if not inp.utxo and not inp.witness_utxo_is_provably_segwit(utxo):
unverified_witness_utxo.append(i)
# Look at what kind of input this will be, and therefore what
# type of signing will be required, and which key we need.
# - also validates redeem_script when present
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
if inp.required_key and not inp.is_segwit and not inp.utxo:
raise FatalPSBTIssue('Legacy input #%d requires non-witness UTXO' % i)
# determine_my_signing_key is updating fully_signed for multisig inputs
# based on redeem/witness script
if inp.fully_signed:
@ -1898,7 +1914,7 @@ class psbtObject(psbtProxy):
# XXX scan witness data provided, and consider those ins signed if not multisig?
if not foreign:
if not foreign and not unverified_witness_utxo:
# no foreign inputs, we can calculate the total input value
self.total_value_in = total_in
assert total_in > 0 or self.por322, "zero value txn"
@ -1907,9 +1923,15 @@ class psbtObject(psbtProxy):
# OK for multi-party transactions (coinjoin etc.)
assert not self.por322 # cannot have foreign inputs in POR txn
self.total_value_in = None
self.warnings.append(
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
)
if foreign:
self.warnings.append(
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
)
if unverified_witness_utxo:
self.warnings.append(
("Unable to calculate fee", "Some input(s) provided unverified witness UTXO(s): " +
seq_to_str(unverified_witness_utxo))
)
if len(self.presigned_inputs) == self.num_inputs:
# Maybe wrong for multisig cases? Maybe they want to add their

View File

@ -517,6 +517,42 @@ class BasicPSBT:
def as_b64_str(self):
return b64encode(self.as_bytes()).decode()
def convert_witness_utxo_to_utxo(self, idx):
# Test helper: the original prev txn cannot be reconstructed from a
# witness_utxo, so retarget this PSBT input to a synthetic funding txn.
inp = self.inputs[idx]
assert inp.witness_utxo
assert inp.utxo is None
prev_txo = CTxOut()
prev_txo.deserialize(io.BytesIO(inp.witness_utxo))
if self.is_v2():
assert inp.prevout_idx is not None
prevout_idx = inp.prevout_idx
else:
assert self.parsed_txn
txin = self.parsed_txn.vin[idx]
prevout_idx = txin.prevout.n
funding = CTransaction()
funding.nVersion = 2
funding.vin = [CTxIn(COutPoint(0, 0xffffffff), nSequence=0xffffffff)]
funding.vout = [CTxOut(0, b'') for _ in range(prevout_idx)]
funding.vout.append(prev_txo)
funding.calc_sha256()
inp.utxo = funding.serialize_with_witness()
inp.witness_utxo = None
if self.is_v2():
inp.previous_txid = ser_uint256(funding.sha256)
else:
txin.prevout.hash = funding.sha256
self.txn = self.parsed_txn.serialize_with_witness()
return funding
def to_v2(self):
if self.version is None or self.version == 0:
self.version = 2

View File

@ -1279,7 +1279,7 @@ def fake_ms_txn(pytestconfig):
# - but has UTXO's to match needs
from struct import pack
def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, segwit_in=False,
def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None,
outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False, hack_psbt=None,
hack_change_out=False, input_amount=1E8, psbt_v2=None, bip67=True,
violate_script_key_order=False, path_mapper=None, inp_af=AF_P2WSH,
@ -1329,21 +1329,14 @@ def fake_ms_txn(pytestconfig):
)
# lots of supporting details needed for p2sh inputs
if inp_af:
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + sha256(script).digest()
if inp_af == AF_P2WSH:
psbt.inputs[i].witness_script = script
elif inp_af == AF_P2SH:
psbt.inputs[i].redeem_script = script
else:
if segwit_in:
psbt.inputs[i].witness_script = script
else:
psbt.inputs[i].redeem_script = script
assert inp_af == AF_P2WSH_P2SH
psbt.inputs[i].witness_script = script
psbt.inputs[i].redeem_script = b'\0\x20' + sha256(script).digest()
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(pack('<I', j) for j in xfp_path)
@ -1359,10 +1352,10 @@ def fake_ms_txn(pytestconfig):
supply.vout.append(CTxOut(int(input_amount), scriptPubKey))
if not segwit_in:
psbt.inputs[i].utxo = supply.serialize_with_witness()
else:
if inp_af in [AF_P2WSH, AF_P2WSH_P2SH]:
psbt.inputs[i].witness_utxo = supply.vout[-1].serialize()
else:
psbt.inputs[i].utxo = supply.serialize_with_witness()
if lock_time and not i:
seq = 0xfffffffd
@ -1532,9 +1525,9 @@ def test_1of1_multisig_sign(finalize, clear_ms, import_ms_wallet, fake_ms_txn, s
@pytest.mark.bitcoind
@pytest.mark.parametrize('num_ins', [ 15 ])
@pytest.mark.parametrize('M', [ 2, 4, 1])
@pytest.mark.parametrize('segwit', [True, False])
@pytest.mark.parametrize('addr_fmt', [AF_P2SH, AF_P2WSH])
@pytest.mark.parametrize('incl_xpubs', [ True, False ])
def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms,
def test_ms_sign_myself(M, use_regtest, make_myself_wallet, addr_fmt, num_ins, dev, clear_ms,
fake_ms_txn, try_sign, incl_xpubs, bitcoind, sim_root_dir):
# IMPORTANT: wont work if you start simulator with --ms flag. Use no args
@ -1550,9 +1543,9 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev
N = len(keys)
assert M<=N
psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs,
psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs,
outstyles=all_out_styles, change_outputs=list(range(1,num_outs)),
inp_af=AF_P2SH)
inp_af=addr_fmt)
with open(f'{sim_root_dir}/debug/myself-before.psbt', 'w') as f:
f.write(b64encode(psbt).decode())
@ -3114,7 +3107,7 @@ def test_ms_wallet_ordering(clear_ms, import_ms_wallet, try_sign_microsd, fake_m
name = f'ms2'
keys3 = import_ms_wallet(3, 5, name=name, accept=1, do_import=True, addr_fmt="p2wsh")
psbt = fake_ms_txn(5, 5, 3, keys3, outstyles=all_out_styles, segwit_in=True, incl_xpubs=True)
psbt = fake_ms_txn(5, 5, 3, keys3, outstyles=all_out_styles, incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
@ -3135,12 +3128,12 @@ def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wa
import_ms_wallet(M, N, keys=opt, name=name, accept=1, do_import=True,
addr_fmt="p2wsh", descriptor=descriptor)
psbt = fake_ms_txn(5, 5, M, opt, outstyles=all_out_styles,
segwit_in=True, incl_xpubs=True)
incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
for opt_1 in all_options:
# create PSBT with original keys order
psbt = fake_ms_txn(5, 5, M, opt_1, outstyles=all_out_styles,
segwit_in=True, incl_xpubs=True)
incl_xpubs=True)
try_sign_microsd(psbt, encoding='base64')
@ -4102,7 +4095,7 @@ def test_change_output_script_type(clear_ms, import_ms_wallet, start_sign, end_s
sign_check(psbt)
psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh-p2wsh",
change_outputs=[0,1], inp_af=AF_P2SH, segwit_in=True)
change_outputs=[0,1], inp_af=AF_P2SH)
sign_check(psbt)

View File

@ -559,6 +559,9 @@ def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind,
chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo'
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
psbt = b4.as_bytes()
start_sign(psbt)
@ -568,7 +571,6 @@ def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind,
assert split_sory[0] == "Change back:"
assert chg_addr == addr_from_display_format(split_sory[-1])
b4 = BasicPSBT().parse(psbt)
check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[1,])
signed = end_sign(True)
@ -610,6 +612,7 @@ def test_change_fraud_path(start_sign, use_regtest, end_sign, case, check_agains
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
(pubkey, path), = b4.outputs[1].bip32_paths.items()
skp = bytearray(b4.outputs[1].bip32_paths[pubkey])
@ -654,6 +657,7 @@ def test_change_fraud_addr(start_sign, end_sign, use_regtest, check_against_bitc
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
# tweak output addr to garbage
t = CTransaction()
@ -691,6 +695,7 @@ def test_change_p2sh_p2wpkh(start_sign, end_sign, check_against_bitcoind, use_re
psbt = f.read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
t = CTransaction()
t.deserialize(BytesIO(b4.txn))
@ -840,15 +845,16 @@ def test_sign_multisig_partial_fail(start_sign, end_sign):
def test_sign_wutxo(start_sign, set_seed_words, end_sign, cap_story, sim_exec, sim_execfile,
sim_root_dir):
# Example from SomberNight: we can sign it, but signature won't be accepted by
# network because the PSBT lies about the UTXO amount and tries to give away to miners,
# as overly-large fee.
# Example from SomberNight, normalized to use a full UTXO so this test still
# reaches the fee display path after legacy witness-only UTXOs are rejected.
set_seed_words('fault lava rice chest uncle exclude power tornado catalog stool'
' swear rival sun aspect oyster deer pepper exchange scrap toward'
' mix second world shaft')
in_psbt = a2b_hex(open('data/snight-example.psbt', 'rb').read()[:-1])
snight = BasicPSBT().parse(a2b_hex(open('data/snight-example.psbt', 'rb').read()[:-1]))
snight.convert_witness_utxo_to_utxo(0)
in_psbt = snight.as_bytes()
for fin in (False, True):
start_sign(in_psbt, finalize=fin)
@ -1136,6 +1142,7 @@ def test_change_troublesome(dev, start_sign, cap_story, try_path, expect, sim_ro
psbt = open('data/example-change.psbt', 'rb').read()
b4 = BasicPSBT().parse(psbt)
b4.convert_witness_utxo_to_utxo(0)
pubkey = a2b_hex('03c80814536f8e801859fc7c2e5129895b261153f519d4f3418ffb322884a7d7e1')
path = [int(p) if ("'" not in p) else 0x80000000+int(p[:-1])
@ -1769,6 +1776,104 @@ def test_own_utxo_missing(segwit_in, num_missing, dev, fake_txn, start_sign, cap
assert "Missing own UTXO(s)" in story
press_cancel()
def _replace_input_utxo_with_witness_utxo(psbt, idx):
inp = psbt.inputs[idx]
txn = CTransaction()
txn.deserialize(BytesIO(inp.utxo))
assert len(txn.vout) == 1
inp.witness_utxo = txn.vout[0].serialize()
inp.utxo = None
def test_nested_segwit_witness_utxo_only_fee_shown(dev, fake_txn, start_sign,
cap_story, end_sign):
psbt = fake_txn(1, 2, dev.master_xpub, segwit_in=True, wrapped=True)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Network fee" in story
assert "unverified witness UTXO" not in story
end_sign(accept=True)
def test_own_legacy_witness_utxo_only_fails(dev, fake_txn, start_sign, cap_story, press_cancel):
def hack(psbt):
_replace_input_utxo_with_witness_utxo(psbt, 0)
psbt = fake_txn(1, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Legacy input #0 requires non-witness UTXO" in story
press_cancel()
def test_foreign_legacy_witness_utxo_only_ok(dev, fake_txn, start_sign, cap_story, end_sign):
def hack(psbt):
pk = list(psbt.inputs[1].bip32_paths.keys())[0]
pp = psbt.inputs[1].bip32_paths[pk]
psbt.inputs[1].bip32_paths[pk] = b'what' + pp[4:]
_replace_input_utxo_with_witness_utxo(psbt, 1)
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Limited Signing" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Legacy input #1 requires non-witness UTXO" not in story
signed = end_sign(accept=True)
assert signed != psbt
def test_mismatched_p2sh_witness_program_unverified(dev, fake_txn, start_sign,
cap_story, end_sign):
def hack(psbt):
pk = list(psbt.inputs[1].bip32_paths.keys())[0]
pp = psbt.inputs[1].bip32_paths[pk]
psbt.inputs[1].bip32_paths[pk] = b'what' + pp[4:]
inp = psbt.inputs[1]
txn = CTransaction()
txn.deserialize(BytesIO(inp.utxo))
assert len(txn.vout) == 1
redeem_script = bytes([0, 20]) + os.urandom(32)
inp.redeem_script = redeem_script
inp.witness_utxo = CTxOut(txn.vout[0].nValue,
bytes([0xa9, 0x14]) + hash160(redeem_script) + bytes([0x87])).serialize()
inp.utxo = None
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Limited Signing" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Network fee" not in story
signed = end_sign(accept=True)
assert signed != psbt
def test_presigned_own_legacy_witness_utxo_only_ok(dev, fake_txn, start_sign, cap_story, end_sign):
def hack(psbt):
pubkey = list(psbt.inputs[1].bip32_paths.keys())[0]
psbt.inputs[1].part_sigs[pubkey] = os.urandom(71)
_replace_input_utxo_with_witness_utxo(psbt, 1)
psbt = fake_txn(2, 2, dev.master_xpub, segwit_in=False, psbt_hacker=hack)
start_sign(psbt)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SEND?"
assert "Partly Signed Already" in story
assert "Unable to calculate fee" in story
assert "Some input(s) provided unverified witness UTXO(s): 1" in story
assert "Legacy input #1 requires non-witness UTXO" not in story
signed = end_sign(accept=True)
assert signed != psbt
@pytest.mark.bitcoind
def test_bitcoind_missing_foreign_utxo(bitcoind, bitcoind_d_sim_watch, microsd_path, try_sign):
# batch tx created from three different psbts (using joinpsbts)

View File

@ -734,7 +734,7 @@ def test_teleport_real_ms(dev, fake_ms_txn):
# match the default paths created by CC in airgapped MS wallet creation.
return str_to_path(deriv)
psbt = fake_ms_txn(3, 2, M, keys, fee=10000, outvals=None, segwit_in=False,
psbt = fake_ms_txn(3, 2, M, keys, fee=10000, outvals=None, inp_af=AF_P2WSH,
outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False,
hack_change_out=False, input_amount=1E8, path_mapper=p2wsh_mapper)