multisig input/output address format
What changed, and why it matters
This commit fixes how COLDCARD handles different Bitcoin address formats in multisig transactions. Previously, the wallet could be tricked into treating inputs or change outputs as belonging to the wrong address type (for example, treating a classic P2SH multisig input as if it were a wrapped SegWit P2SH-P2WSH input, or vice versa). The patch makes the device strictly check that the script type matches the wallet's configured address format and verifies that the script in the transaction actually produces the expected on-chain scriptPubKey. This prevents attackers from deceiving the device about which coins are being spent or where change is going.
Treat this as a security fix and include it in the next firmware release. Users running affected firmware should upgrade before signing multisig transactions, especially when co-signers or PSBT sources are not fully trusted. Review any custom PSBT tooling to ensure it uses the correct redeem/witness scripts for the declared address format.
Security signals we found
Fixes type confusion between P2SH, P2WSH, and P2WSH-P2SH multisig inputs/outputs
Adds scriptPubKey equality check for multisig inputs against reconstructed scripts
Adds address-format matching between active multisig wallet and PSBT inputs/outputs
Prevents change-output fraud where a different script type is presented as change
Includes regression tests for mismatched input/output script types and swapped redeem/witness scripts
Evidence from the diff
The patch refactors scriptPubKey construction into chains.script_pubkey() and uses it consistently for both single-key and multisig address rendering. In psbt.py, it replaces string address-format identifiers with AF_* constants and adds explicit script-type matching for multisig inputs and change outputs. Key changes include: requiring the active multisig wallet’s addr_fmt to match the PSBT’s input/output address format; validating that the reconstructed redeem/witness script hashes to the UTXO’s scriptPubKey; and preventing a P2SH wallet from accepting P2WSH or P2WSH-P2SH inputs/outputs as its own. New tests verify that mismatched input/output script types are rejected and that swapped redeem/witness scripts are caught as ‘spk mismatch’.
Changed components
shared/chains.pyshared/multisig.pyshared/psbt.pyshared/serializations.pyshared/usb.pytesting/test_multisig.pytesting/test_nfc.pytesting/test_teleport.pyInspect captured patch +377 / −99
diff --git a/shared/chains.py b/shared/chains.py
index ef8c134..8a35b1f 100644
--- a/shared/chains.py
+++ b/shared/chains.py
@@ -80,6 +80,41 @@ class ChainsBase:
or (version == cls.slip132[addr_fmt].priv)
return node
+ @classmethod
+ def script_pubkey(cls, addr_fmt, pubkey=None, script=None):
+ digest = None
+ if addr_fmt & AFC_SCRIPT:
+ assert script, "need witness/redeem script"
+
+ if addr_fmt in [AF_P2WSH, AF_P2WSH_P2SH]:
+ digest = ngu.hash.sha256s(script)
+ # bech32 encoded segwit p2sh
+ spk = b'\x00\x20' + digest
+ if addr_fmt == AF_P2WSH_P2SH:
+ # segwit p2wsh encoded as classic P2SH
+ digest = hash160(spk)
+ spk = b'\xA9\x14' + digest + b'\x87'
+
+ else:
+ assert addr_fmt == AF_P2SH
+ digest = hash160(script)
+ spk = b'\xA9\x14' + digest + b'\x87'
+
+ else:
+ assert pubkey
+ keyhash = ngu.hash.hash160(pubkey)
+ if addr_fmt == AF_CLASSIC:
+ spk = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
+ elif addr_fmt == AF_P2WPKH_P2SH:
+ redeem_script = b'\x00\x14' + keyhash
+ spk = b'\xA9\x14' + ngu.hash.hash160(redeem_script) + b'\x87'
+ elif addr_fmt == AF_P2WPKH:
+ spk = b'\x00\x14' + keyhash
+ else:
+ raise ValueError('bad address template: %s' % addr_fmt)
+
+ return spk, digest
+
@classmethod
def p2sh_address(cls, addr_fmt, witdeem_script):
# Multisig and general P2SH support
@@ -91,21 +126,14 @@ class ChainsBase:
# - returns: str(address)
assert addr_fmt & AFC_SCRIPT, 'for p2sh only'
- assert witdeem_script, "need witness/redeem script"
-
- if addr_fmt & AFC_SEGWIT:
- digest = ngu.hash.sha256s(witdeem_script)
- else:
- digest = hash160(witdeem_script)
+ _, digest = cls.script_pubkey(addr_fmt, script=witdeem_script)
- if addr_fmt & AFC_BECH32:
+ if addr_fmt == AF_P2WSH:
# bech32 encoded segwit p2sh
addr = ngu.codecs.segwit_encode(cls.bech32_hrp, 0, digest)
- elif addr_fmt == AF_P2WSH_P2SH:
- # segwit p2wsh encoded as classic P2SH
- addr = ngu.codecs.b58_encode(cls.b58_script + hash160(b'\x00\x20' + digest))
else:
- # P2SH classic
+ # segwit p2wsh encoded as classic P2SH
+ # and P2SH classic
addr = ngu.codecs.b58_encode(cls.b58_script + digest)
return addr
@@ -115,20 +143,8 @@ class ChainsBase:
# - renders a pubkey to an address
# - works only with single-key addresses
assert not addr_fmt & AFC_SCRIPT
-
- keyhash = ngu.hash.hash160(pubkey)
- if addr_fmt == AF_CLASSIC:
- script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC'
- elif addr_fmt == AF_P2WPKH_P2SH:
- redeem_script = b'\x00\x14' + keyhash
- scripthash = ngu.hash.hash160(redeem_script)
- script = b'\xA9\x14' + scripthash + b'\x87'
- elif addr_fmt == AF_P2WPKH:
- script = b'\x00\x14' + keyhash
- else:
- raise ValueError('bad address template: %s' % addr_fmt)
-
- return cls.render_address(script)
+ spk, _ = cls.script_pubkey(addr_fmt, pubkey=pubkey)
+ return cls.render_address(spk)
@classmethod
def address(cls, node, addr_fmt):
@@ -458,6 +474,15 @@ def addr_fmt_label(addr_fmt):
AF_P2WPKH_P2SH: "P2SH-Segwit",
AF_P2WPKH: "Segwit P2WPKH"}[addr_fmt]
+
+def addr_fmt_str(addr_fmt):
+ return {AF_CLASSIC: "p2pkh",
+ AF_P2SH: "p2sh",
+ AF_P2WPKH: "p2wpkh",
+ AF_P2WSH: "p2wsh",
+ AF_P2WPKH_P2SH: "p2sh-p2wpkh",
+ AF_P2WSH_P2SH: "p2sh-p2wsh"}[addr_fmt]
+
def verify_recover_pubkey(sig, digest):
# verifies a message digest against a signature and recovers
# the address type and public key that did the signing
diff --git a/shared/multisig.py b/shared/multisig.py
index 83993d4..1e692d4 100644
--- a/shared/multisig.py
+++ b/shared/multisig.py
@@ -327,11 +327,12 @@ class MultisigWallet(WalletABC):
return True
- def assert_matching(self, M, N, xfp_paths):
+ def assert_matching(self, M, N, xfp_paths, addr_fmt):
# compare in-memory wallet with details recovered from PSBT
# - xfp_paths must be sorted already
assert (self.M, self.N) == (M, N), "M/N mismatch"
assert len(xfp_paths) == N, "XFP count"
+ assert self.addr_fmt == addr_fmt, "addr fmt"
if self.disable_checks: return
assert self.matching_subpaths(xfp_paths), "wrong XFP/derivs"
diff --git a/shared/psbt.py b/shared/psbt.py
index f17648a..daba9f2 100644
--- a/shared/psbt.py
+++ b/shared/psbt.py
@@ -2,7 +2,7 @@
#
# psbt.py - understand PSBT file format: verify and generate them
#
-import stash, gc, history, sys, ngu, ckcc
+import stash, gc, history, sys, ngu, ckcc, chains
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
from utils import xfp2str, B2A, keypath_to_str
@@ -18,7 +18,7 @@ from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import ser_sig_der, uint256_from_str, ser_push_data
from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY
from serializations import ALL_SIGHASH_FLAGS
-from opcodes import OP_CHECKMULTISIG
+from opcodes import OP_CHECKMULTISIG, OP_RETURN
from glob import settings
from public_constants import (
@@ -30,7 +30,8 @@ from public_constants import (
PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT,
PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID,
PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME,
- PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS
+ PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS,
+ AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH, AF_P2SH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, AF_BARE_PK
)
# PSBT proprietary keytype
@@ -403,7 +404,7 @@ class psbtOutputProxy(psbtProxy):
# - must match expected address for this output, coming from unsigned txn
af, addr_or_pubkey, is_segwit = txo.get_address()
- if (num_ours == 0) or (af in ["p2tr", "op_return", None]):
+ if (num_ours == 0) or (af in [AF_P2TR, OP_RETURN, None]):
# num_ours == 0
# - not considered fraud because other signers looking at PSBT may have them
# - user will see them as normal outputs, which they are from our PoV.
@@ -422,7 +423,7 @@ class psbtOutputProxy(psbtProxy):
# p2wsh/p2sh cases need full set of pubkeys, and therefore redeem script
expect_pubkey = None
- if af == 'p2pk':
+ if af == AF_BARE_PK:
# output is public key (not a hash, much less common)
assert len(addr_or_pubkey) == 33
@@ -435,7 +436,7 @@ class psbtOutputProxy(psbtProxy):
# Figure out what the hashed addr should be
pkh = addr_or_pubkey
- if af == 'p2sh':
+ if af in [AF_P2SH, AF_P2WSH]:
# P2SH or Multisig output
# Can be both, or either one depending on address type
@@ -449,7 +450,8 @@ class psbtOutputProxy(psbtProxy):
# But definitely required, else we don't know what script we're sending to.
raise FatalPSBTIssue("Missing redeem script for output #%d" % out_idx)
- target_spk = bytes([0xa9, 0x14]) + hash160(redeem_script) + bytes([0x87])
+ target_spk, _ = chains.current_chain().script_pubkey(AF_P2WPKH_P2SH,
+ pubkey=expect_pubkey)
if not is_segwit and len(redeem_script) == 22 and \
redeem_script[0] == 0 and redeem_script[1] == 20 and \
txo.scriptPubKey == target_spk:
@@ -488,6 +490,17 @@ class psbtOutputProxy(psbtProxy):
self.is_change = False
return af
+ if (af == AF_P2SH) and (redeem_script and witness_script) and \
+ (len(redeem_script) == 34) and \
+ (redeem_script[0]) == 0 and (redeem_script[1] == 32):
+ # can also check if redeem script hashes to hash160 and compare with scriptPubKey
+ af = AF_P2WSH_P2SH
+
+ # no need to proceed to script verification if address format does not match
+ if af != active_multisig.addr_fmt:
+ self.is_change = False
+ return af
+
# redeem script must be exactly what we expect
# - pubkeys will be reconstructed from derived paths here
# - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor)
@@ -526,7 +539,7 @@ class psbtOutputProxy(psbtProxy):
# old BIP-16 style; looks like payment addr
expect_pkh = hash160(redeem_script)
- elif af == 'p2pkh':
+ elif af in [AF_CLASSIC, AF_P2WPKH]:
# input is hash160 of a single public key
assert len(addr_or_pubkey) == 20
expect_pkh = hash160(expect_pubkey)
@@ -744,10 +757,10 @@ class psbtInputProxy(psbtProxy):
which_key = None
addr_type, addr_or_pubkey, addr_is_segwit = utxo.get_address()
- if addr_type == "op_return":
+ if addr_type == OP_RETURN:
self.required_key = None
return
- if addr_type == "p2tr":
+ if addr_type == AF_P2TR:
raise FatalPSBTIssue("Install EDGE firmware to spend taproot.")
if addr_type is None:
# If this is reached, we do not understand the output well
@@ -757,7 +770,7 @@ class psbtInputProxy(psbtProxy):
if addr_is_segwit and not self.is_segwit:
self.is_segwit = True
- if addr_type == 'p2sh':
+ if addr_type in [AF_P2SH, AF_P2WSH]:
# multisig input
self.is_p2sh = True
@@ -793,7 +806,7 @@ class psbtInputProxy(psbtProxy):
len(redeem_script) == 22 and \
redeem_script[0] == 0 and redeem_script[1] == 20:
# it's actually segwit p2pkh inside p2sh
- addr_type = 'p2sh-p2wpkh'
+ addr_type = AF_P2WPKH_P2SH
addr = redeem_script[2:22]
self.is_segwit = True
else:
@@ -802,10 +815,10 @@ class psbtInputProxy(psbtProxy):
if self.witness_script and not self.is_segwit and self.is_multisig:
# bugfix
- addr_type = 'p2sh-p2wsh'
+ addr_type = AF_P2WSH_P2SH
self.is_segwit = True
- elif addr_type == 'p2pkh':
+ elif addr_type in [AF_CLASSIC, AF_P2WPKH]:
# input is hash160 of a single public key
self.scriptSig = utxo.scriptPubKey
addr = addr_or_pubkey
@@ -818,7 +831,7 @@ class psbtInputProxy(psbtProxy):
# none of the pubkeys provided hashes to that address
raise FatalPSBTIssue('Input #%d: pubkey vs. address wrong' % my_idx)
- elif addr_type == 'p2pk':
+ elif addr_type == AF_BARE_PK:
# input is single public key (less common)
self.scriptSig = utxo.scriptPubKey
assert len(addr_or_pubkey) == 33
@@ -844,33 +857,36 @@ class psbtInputProxy(psbtProxy):
xfp_paths = list(self.subpaths.values())
xfp_paths.sort()
+ # only search wallets with correct script type (aka address format)
if not psbt.active_multisig:
# search for multisig wallet
- wal = MultisigWallet.find_match(M, N, xfp_paths)
+ wal = MultisigWallet.find_match(M, N, xfp_paths, [addr_type])
if not wal:
raise FatalPSBTIssue('Unknown multisig wallet')
psbt.active_multisig = wal
else:
# check consistent w/ already selected wallet
- psbt.active_multisig.assert_matching(M, N, xfp_paths)
+ psbt.active_multisig.assert_matching(M, N, xfp_paths, addr_type)
# validate redeem script, by disassembling it and checking all pubkeys
try:
psbt.active_multisig.validate_script(redeem_script, subpaths=self.subpaths)
+ target_spk, _ = chains.current_chain().script_pubkey(addr_type, script=redeem_script)
+ assert target_spk == utxo.scriptPubKey, "spk mismatch"
except BaseException as exc:
# sys.print_exception(exc)
raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc))
if not which_key and DEBUG:
print("no key: input #%d: type=%s segwit=%d a_or_pk=%s scriptPubKey=%s" % (
- my_idx, addr_type, self.is_segwit or 0,
+ my_idx, chains.addr_fmt_str(addr_type), self.is_segwit or 0,
b2a_hex(addr_or_pubkey), b2a_hex(utxo.scriptPubKey)))
self.required_key = which_key
if self.is_segwit:
- if ('pkh' in addr_type):
+ if addr_type in [AF_P2WPKH, AF_P2WPKH_P2SH]:
# This comment from <https://bitcoincore.org/en/segwit_wallet_dev/>:
#
# Please note that for a P2SH-P2WPKH, the scriptCode is always 26
@@ -1504,7 +1520,7 @@ class psbtObject(psbtProxy):
assert txo.nValue >= 0, "negative output value: o%d" % idx
total_out += txo.nValue
- if (txo.nValue == 0) and (af != "op_return"):
+ if (txo.nValue == 0) and (af != OP_RETURN):
# OP_RETURN outputs have nValue=0 standard
zero_val_outs += 1
@@ -1512,7 +1528,7 @@ class psbtObject(psbtProxy):
self.num_change_outputs += 1
total_change += txo.nValue
- if af == "op_return":
+ if af == OP_RETURN:
num_op_return += 1
if len(txo.scriptPubKey) > 83:
num_op_return_size += 1
diff --git a/shared/serializations.py b/shared/serializations.py
index e0caf05..cb4cb25 100755
--- a/shared/serializations.py
+++ b/shared/serializations.py
@@ -19,6 +19,7 @@ from ubinascii import hexlify as b2a_hex
import ustruct as struct
import ngu
from opcodes import *
+from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2SH, AF_P2WSH, AF_P2TR, AF_BARE_PK
# single-shot hash functions
sha256 = ngu.hash.sha256s
@@ -355,26 +356,30 @@ class CTxOut(object):
# (addr_type_code, addr, is_segwit)
# 'addr' is byte string, either 20 or 32 long
if self.is_p2tr():
- return 'p2tr', self.scriptPubKey[2:2+32], True
+ return AF_P2TR, self.scriptPubKey[2:2+32], True
if self.is_p2wpkh():
- return 'p2pkh', self.scriptPubKey[2:2+20], True
+ return AF_P2WPKH, self.scriptPubKey[2:2+20], True
if self.is_p2wsh():
- return 'p2sh', self.scriptPubKey[2:2+32], True
+ return AF_P2WSH, self.scriptPubKey[2:2+32], True
if self.is_p2pkh():
- return 'p2pkh', self.scriptPubKey[3:3+20], False
+ return AF_CLASSIC, self.scriptPubKey[3:3+20], False
if self.is_p2sh():
- return 'p2sh', self.scriptPubKey[2:2+20], False
+ # can be:
+ # * bare P2SH
+ # * P2SH-P2WPKH
+ # * P2SH-P2WSH
+ return AF_P2SH, self.scriptPubKey[2:2+20], False
if self.is_p2pk():
# rare, pay to full pubkey
- return 'p2pk', self.scriptPubKey[2:2+33], False
+ return AF_BARE_PK, self.scriptPubKey[2:2+33], False
if self.scriptPubKey[0] == OP_RETURN:
- return 'op_return', self.scriptPubKey, False
+ return OP_RETURN, self.scriptPubKey, False
return None, self.scriptPubKey, None
diff --git a/shared/usb.py b/shared/usb.py
index 44f5c5a..e035093 100644
--- a/shared/usb.py
+++ b/shared/usb.py
@@ -233,7 +233,7 @@ class USBHandler:
except CCBusyError:
# auth UX is doing something else
resp = b'busy'
- except SpendPolicyViolation:
+ except SpendPolicyViolation as e:
resp = b'err_Spending policy in effect'
except HSMDenied:
resp = b'err_Not allowed in HSM mode'
@@ -256,6 +256,7 @@ class USBHandler:
raise exc
except Exception as exc:
# catch bugs and fuzzing too
+ # sys.print_exception(exc)
if is_simulator() or is_devmode:
print("USB request caused this: ", end='')
# sys.print_exception(exc)
diff --git a/testing/test_multisig.py b/testing/test_multisig.py
index 31341ba..190c597 100644
--- a/testing/test_multisig.py
+++ b/testing/test_multisig.py
@@ -289,6 +289,10 @@ def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select,
# render as a file for import
config = f"name: {name}\npolicy: {M} / {N}\n\n"
+ if addr_fmt is None:
+ # default now is segwit v0
+ addr_fmt = "p2wsh"
+
if addr_fmt:
if isinstance(addr_fmt, int):
addr_fmt = addr_fmt_names[addr_fmt]
@@ -1240,7 +1244,7 @@ def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_m
config = f"name: Myself-{M}\npolicy: {M} / 4\n\n"
if addr_fmt:
- config += f'format: {addr_fmt.upper()}\n'
+ config += f'format: {addr_fmt.upper()}\n' # default is sh
config += '\n'.join('%s: %s' % (xfp2str(xfp), sk.hwif()) for xfp, _, sk in keys)
#print(config)
@@ -1279,7 +1283,8 @@ def fake_ms_txn(pytestconfig):
def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, segwit_in=False,
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):
+ violate_script_key_order=False, path_mapper=None, inp_af=AF_P2WSH,
+ force_outstyle=None):
psbt = BasicPSBT()
if psbt_v2 is None:
@@ -1315,14 +1320,28 @@ def fake_ms_txn(pytestconfig):
# - each input is 1BTC
# addr where the fake money will be stored.
- addr, scriptPubKey, script, details = make_ms_address(M, keys, idx=i, bip67=bip67,
- violate_script_key_order=violate_script_key_order, path_mapper=path_mapper)
+ addr, scriptPubKey, script, details = make_ms_address(
+ M, keys, idx=i, bip67=bip67, addr_fmt=inp_af,
+ violate_script_key_order=violate_script_key_order,
+ path_mapper=path_mapper
+ )
# lots of supporting details needed for p2sh inputs
- if segwit_in:
- psbt.inputs[i].witness_script = script
+ 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()
+
else:
- psbt.inputs[i].redeem_script = script
+ if segwit_in:
+ psbt.inputs[i].witness_script = script
+ else:
+ psbt.inputs[i].redeem_script = script
for pubkey, xfp_path in details:
psbt.inputs[i].bip32_paths[pubkey] = b''.join(pack('<I', j) for j in xfp_path)
@@ -1363,6 +1382,12 @@ def fake_ms_txn(pytestconfig):
style = outstyles[i % len(outstyles)]
if i in change_outputs:
+ # overwrite style, change can only be of THE style
+ if force_outstyle:
+ style = force_outstyle
+ else:
+ style = addr_fmt_names[inp_af]
+
make_redeem_args = dict()
if hack_change_out:
make_redeem_args = hack_change_out(i)
@@ -1378,7 +1403,7 @@ def fake_ms_txn(pytestconfig):
if 'w' in style:
psbt.outputs[i].witness_script = scr
- if style.endswith('p2sh'):
+ if 'p2sh' in style:
psbt.outputs[i].redeem_script = b'\0\x20' + sha256(scr).digest()
elif style.endswith('sh'):
psbt.outputs[i].redeem_script = scr
@@ -1446,7 +1471,7 @@ def test_ms_sign_simple(M_N, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, impor
keys = import_ms_wallet(M, N, name='cli-test', accept=True, addr_fmt=addr_fmt,
do_import=do_import, descriptor=descriptor, bip67=bip67)
- psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs,
+ psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs, inp_af=addr_fmt,
outstyles=ADDR_STYLES_MS, change_outputs=[1] if has_change else [],
bip67=bip67)
@@ -1480,8 +1505,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,
- outstyles=all_out_styles, change_outputs=list(range(1,num_outs)))
+ psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs,
+ outstyles=all_out_styles, change_outputs=list(range(1,num_outs)),
+ inp_af=AF_P2SH)
with open(f'{sim_root_dir}/debug/myself-before.psbt', 'w') as f:
f.write(b64encode(psbt).decode())
@@ -1935,25 +1961,22 @@ def test_ms_sign_bitrot(num_ins, dev, addr_fmt, clear_ms, incl_xpubs, import_ms_
assert story.strip() in str(ee)
assert len(story.split(':')[-1].strip()), story
-@pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH] )
-@pytest.mark.parametrize('num_ins', [ 1])
+@pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH] )
+@pytest.mark.parametrize('num_ins', [1])
@pytest.mark.parametrize('incl_xpubs', [ True])
-@pytest.mark.parametrize('out_style', ['p2wsh'])
-@pytest.mark.parametrize('pk_num', range(4))
+@pytest.mark.parametrize('pk_num', range(4))
@pytest.mark.parametrize('case', ['pubkey', 'path'])
-def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, make_multisig,
- addr_vs_path, fake_ms_txn, start_sign, end_sign, out_style, cap_story,
- sim_root_dir):
+def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, import_ms_wallet,
+ addr_vs_path, fake_ms_txn, start_sign, end_sign, cap_story, sim_root_dir):
M = 1
N = 3
num_outs = 2
clear_ms()
- keys = make_multisig(M, N)
+ keys = import_ms_wallet(M, N, addr_fmt=addr_fmt, accept=True)
-
- # given
+ # given
def tweak(case, pk_num, data):
# added from make_redeem() as tweak_pubkeys option
#(pk, xfp, path))
@@ -1973,26 +1996,29 @@ def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xp
assert False, case
data[pk_num] = (pk, xfp, path)
- psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=True,
- outstyles=[out_style], change_outputs=[0],
+ psbt = fake_ms_txn(num_ins, num_outs, M, keys,
+ change_outputs=[0], inp_af=addr_fmt,
hack_change_out=lambda idx: dict(tweak_pubkeys=
lambda data: tweak(case, pk_num, data)))
with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f:
f.write(psbt)
+ start_sign(psbt)
+
+ # Check error details are shown
+ time.sleep(.5)
+ title, story = cap_story()
+
+ assert len(story.split(':')[-1].strip()), story
+
with pytest.raises(Exception) as ee:
- start_sign(psbt)
- signed = end_sign(accept=True, accept_ms_import=False)
+ end_sign(accept=True, accept_ms_import=False)
assert 'Output#0:' in str(ee)
assert 'P2WSH or P2SH change output script' in str(ee)
#assert 'Deception regarding change output' in str(ee)
- # Check error details are shown
- time.sleep(.5)
- title, story = cap_story()
assert story.strip() in str(ee.value.args[0])
- assert len(story.split(':')[-1].strip()), story
@pytest.mark.parametrize('repeat', range(2) )
@@ -3333,7 +3359,7 @@ def test_bare_cc_ms_qr_import(N, make_multisig, scan_a_qr, clear_ms, goto_home,
[("p2wsh", 1000000, 0)] * 99,
[("p2sh", 1000000, 1)] * 33,
[("p2wsh-p2sh", 1000000, 1)] * 18 + [("p2wsh", 50000000, 0)] * 12,
- [("p2sh", 1000000, 1), ("p2wsh-p2sh", 50000000, 0), ("p2wsh", 800000, 1)] * 14,
+ [("p2sh", 1000000, 0), ("p2wsh-p2sh", 50000000, 0), ("p2wsh", 800000, 1)] * 14,
])
def test_txout_explorer(data, clear_ms, import_ms_wallet, fake_ms_txn,
start_sign, txout_explorer, desc, pytestconfig):
@@ -3343,24 +3369,28 @@ def test_txout_explorer(data, clear_ms, import_ms_wallet, fake_ms_txn,
descriptor, bip67 = False, True
if desc == "multi":
descriptor, bip67 = True, False
- keys = import_ms_wallet(2, 3, name='ms-test', accept=True,
- descriptor=descriptor, bip67=bip67)
outstyles = []
outvals = []
change_outputs = []
+ the_style = "p2wsh"
for i in range(len(data)):
os, ov, is_change = data[i]
outstyles.append(os)
outvals.append(ov)
if is_change:
+ # only one style will always be the change
+ the_style = os
change_outputs.append(i)
+ keys = import_ms_wallet(2, 3, name='ms-test', accept=True,
+ descriptor=descriptor, bip67=bip67, addr_fmt=the_style)
+
inp_amount = sum(outvals) + 100000 # 100k sat fee
psbt = fake_ms_txn(1, len(data), M, keys, outstyles=outstyles,
outvals=outvals, change_outputs=change_outputs,
- input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2'),
- bip67=bip67)
+ inp_af=unmap_addr_fmt[the_style], bip67=bip67,
+ input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2'))
start_sign(psbt)
txout_explorer(data)
@@ -3787,4 +3817,199 @@ def test_multisig_nfc_qr_finalization(way, clear_ms, make_multisig, import_ms_wa
assert is_fin
+
+def test_input_script_type(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story,
+ press_cancel, settings_set, fake_ms_txn):
+
+ def sign_check(psbt):
+ # start sign MUST raise scriptPubKey mismatch on inputs or change outputs
+ # it does not in current master
+ start_sign(psbt)
+ _, story = cap_story()
+ try:
+ end_sign()
+ assert False, story
+ except Exception as e:
+ assert e.args[0] == 'Coldcard Error: Unknown multisig wallet'
+ return
+
+ clear_ms()
+ M, N = 2, 3
+ wname = "bugg"
+ # import wallet with script type p2wsh
+ keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, descriptor=True)
+
+ # create txn with p2sh inputs
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2SH,
+ change_outputs=[0,1])
+ sign_check(psbt)
+
+ # create txn with p2sh-p2wsh
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys,
+ change_outputs=[0,1], inp_af=AF_P2WSH_P2SH)
+
+ sign_check(psbt)
+
+ # ============================
+
+ clear_ms()
+ # import wallet with script type p2sh-p2wsh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True)
+
+ # create txn with p2wsh inputs
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys,
+ change_outputs=[0,1], inp_af=AF_P2WSH)
+
+ sign_check(psbt)
+
+ # create txn with p2sh inputs
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys,
+ change_outputs=[0,1], inp_af=AF_P2SH)
+
+ sign_check(psbt)
+
+ # ============================
+
+ clear_ms()
+ # import wallet with script type p2sh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True)
+
+ # create txn with p2wsh inputs
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys,
+ change_outputs=[0,1], inp_af=AF_P2WSH)
+
+ sign_check(psbt)
+
+ # create txn with p2sh-p2wsh inputs
+ # we shouldn't even recognize these input as ours
+ psbt = fake_ms_txn(2, 2, M, keys,
+ change_outputs=[0,1], inp_af=AF_P2WSH_P2SH)
+
+ sign_check(psbt)
+
+
+def test_change_output_script_type(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story,
+ press_cancel, settings_set, fake_ms_txn):
+
+ def sign_check(psbt):
+ # start sign MUST raise scriptPubKey mismatch on inputs or change outputs
+ # it does not in current master
+ start_sign(psbt)
+ _, story = cap_story()
+ assert "Change back" not in story
+ assert "Consolidating" not in story
+ assert "Sending" in story
+ end_sign() # must work
+
+ clear_ms()
+ M, N = 2, 3
+ wname = "bugg"
+ # import wallet with script type p2wsh
+ keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, descriptor=True)
+
+ # inputs correct, change outputs wrong address format
+ psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh", inp_af=AF_P2WSH,
+ change_outputs=[0,1])
+ sign_check(psbt)
+
+ psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh-p2wsh",
+ change_outputs=[0,1], inp_af=AF_P2WSH)
+
+ sign_check(psbt)
+
+ # ============================
+
+ clear_ms()
+ # import wallet with script type p2sh-p2wsh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True)
+
+ # inputs correct, change outputs wrong address format
+ psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2wsh",
+ change_outputs=[0,1], inp_af=AF_P2WSH_P2SH)
+
+ sign_check(psbt)
+
+ psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh",
+ change_outputs=[0,1], inp_af=AF_P2WSH_P2SH)
+
+ sign_check(psbt)
+
+ # ============================
+
+ clear_ms()
+ M, N = 2, 3
+ wname = "bugg"
+ # import wallet with script type p2sh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True)
+
+ # inputs correct, change outputs wrong address format
+ psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2wsh",
+ change_outputs=[0,1], inp_af=AF_P2SH)
+
+ 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)
+
+ sign_check(psbt)
+
+
+def test_sh_vs_wrapped_segwit_psbt(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story,
+ press_cancel, settings_set, fake_ms_txn):
+
+ clear_ms()
+ M, N = 2, 3
+ wname = "spk_check_sh_shwsh"
+ # import wallet with script type p2sh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True)
+
+ def hack(psbt_in):
+ for inp in psbt_in.inputs:
+ # switch scripts so it looks like bare p2sh instead wrapped segwit script hash
+ # it even has our keys, and script is correct
+ inp.redeem_script = inp.witness_script
+ inp.witness_script = None
+
+ # PSBT has p2sh-p2wsh inputs & outputs
+ # but PSBT creator made a mistake and filled redeem/witness like in p2sh (see hack)
+ psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2WSH_P2SH, hack_psbt=hack)
+
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "OK TO SEND?" not in title
+ assert "spk mismatch" in story
+
+
+def test_wrapped_segwit_vs_sh_psbt(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story,
+ press_cancel, settings_set, fake_ms_txn):
+
+ clear_ms()
+ M, N = 2, 3
+ wname = "spk_check_shwsh_sh"
+ # import wallet with script type p2sh-p2wsh
+ keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True)
+
+ def hack(psbt_in):
+ for inp in psbt_in.inputs:
+ # switch scripts so it looks like bare p2sh instead wrapped segwit script hash
+ # it even has our keys, and script is correct
+ inp.witness_script = inp.redeem_script
+ inp.redeem_script = b"\x00\x20" + sha256(inp.witness_script).digest()
+
+ # PSBT has p2sh inputs & outputs
+ # but PSBT creator made a mistake and filled redeem/witness like in p2sh (see hack)
+ psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2SH, hack_psbt=hack)
+
+ start_sign(psbt)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "OK TO SEND?" not in title
+ assert "spk mismatch" in story
+
# EOF
diff --git a/testing/test_nfc.py b/testing/test_nfc.py
index 5c3f49c..ada8e83 100644
--- a/testing/test_nfc.py
+++ b/testing/test_nfc.py
@@ -11,7 +11,8 @@ from struct import pack, unpack
import ndef
from hashlib import sha256
from txn import *
-from charcodes import KEY_NFC, KEY_QR
+from constants import unmap_addr_fmt
+from charcodes import KEY_NFC
@pytest.mark.parametrize('case', range(6))
@@ -479,10 +480,10 @@ def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove,
goto_home()
# create 1 of 3 multiig wallet - no need for another signers to make tx final
M, N = 1, 3
- keys = import_ms_wallet(M, N, random.choice(["p2wsh", "p2sh-p2wsh", "p2sh"]),
- name="ms_pushtx", accept=True, way=way, netcode=chain,
+ af = random.choice(["p2wsh", "p2sh-p2wsh", "p2sh"])
+ keys = import_ms_wallet(M, N, af, name="ms_pushtx", accept=True, way=way, netcode=chain,
force_unsort_ms=random.getrandbits(1))
- psbt = fake_ms_txn(2, num_outs, M, keys)
+ psbt = fake_ms_txn(2, num_outs, M, keys, inp_af=unmap_addr_fmt[af])
else:
psbt = fake_txn(2, num_outs)
diff --git a/testing/test_teleport.py b/testing/test_teleport.py
index 51070af..320e846 100644
--- a/testing/test_teleport.py
+++ b/testing/test_teleport.py
@@ -419,14 +419,14 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite
@pytest.mark.unfinalized
@pytest.mark.parametrize('num_ins', [ 15 ])
@pytest.mark.parametrize('M', [4])
-@pytest.mark.parametrize('segwit', [True])
+@pytest.mark.parametrize('hobbled', [True, False])
@pytest.mark.parametrize('incl_xpubs', [ False ])
-def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms,
+def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, num_ins, dev, clear_ms, hobbled,
fake_ms_txn, try_sign, incl_xpubs, bitcoind, cap_story, need_keypress,
cap_menu, pick_menu_item, grab_payload, rx_complete, press_select,
ndef_parse_txn_psbt, press_nfc, nfc_read, settings_get, settings_set,
- txid_from_export_prompt, sim_root_dir,
- set_hobble, readback_bbqr, nfc_is_enabled):
+ txid_from_export_prompt, sim_root_dir, set_hobble, readback_bbqr,
+ nfc_is_enabled, goto_home):
# IMPORTANT: won't work if you start simulator with --ms flag. Use no args
all_out_styles = list(unmap_addr_fmt.keys())
@@ -436,11 +436,15 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, segwit, num_ins, d
use_regtest()
# create a wallet, with 3 bip39 pw's
- keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs))
+ keys, select_wallet = make_myself_wallet(M, addr_fmt="p2wsh", do_import=(not incl_xpubs))
N = len(keys)
assert M<=N
- psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs,
+ if hobbled:
+ set_hobble(True, {'okeys'})
+ goto_home()
+
+ psbt = fake_ms_txn(num_ins, num_outs, M, keys, inp_af=AF_P2WSH, incl_xpubs=incl_xpubs,
outstyles=all_out_styles, change_outputs=list(range(1,num_outs)))
with open(f'{sim_root_dir}/debug/myself-before.psbt', 'wb') as f:
Why this scored 72/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.