What changed, and why it matters
This commit tightens how COLDCARD reads PSBTv2 (Partially Signed Bitcoin Transaction version 2) files. It now rejects several malformed or ambiguous encodings that could previously be accepted: extra key data attached to fields that should have none, badly encoded input/output counts, and PSBTv2 files that are missing the required global version field. These are defensive hardening fixes rather than a single obvious exploit, but they close paths where a malicious or malformed PSBT could confuse the wallet's transaction parser.
Treat as a security-relevant hardening patch. Users should upgrade to a firmware release containing this commit, and developers should review whether any other PSBTv2 fields are still missing singleton validation.
Security signals we found
PSBT parser hardening
rejection of non-canonical compact-size encodings
enforcement of singleton key-data emptiness
mandatory PSBTv2 global version field
defensive input validation
Evidence from the diff
The patch updates shared/psbt.py to treat more PSBTv2 global, input, and output fields as ‘singleton’ no_keys fields (fields whose key data must be empty). It also validates that PSBT_GLOBAL_INPUT_COUNT and PSBT_GLOBAL_OUTPUT_COUNT values serialize canonically, and requires PSBTv2 transactions to explicitly include PSBT_GLOBAL_VERSION. Previously, a missing version was silently treated as v0 if no unsigned transaction was present. Tests are added for each rejected case.
Changed components
shared/psbt.pyPSBTv2 parsertransaction signing flowInspect captured patch +90 / −11
### releases/Next-ChangeLog.md
@@ -12,6 +12,10 @@ your addition and anything else already in this file.**
- Enhancement: Support per-input required height and time locktimes in PSBTv2 transactions.
+- Bugfix: Harden PSBTv2 parsing by rejecting key data on singleton fields,
+ malformed global input/output count encodings, and files missing the required
+ global version.
+
# Mk Specific Changes
@@ -25,4 +29,3 @@ your addition and anything else already in this file.**
## 1.5.3Q - 2026-09-xx
- tbd
-
### shared/psbt.py
@@ -344,7 +344,8 @@ def parse_subpaths(self, my_xfp, parent):
# Track details of each output of PSBT
#
class psbtOutputProxy(psbtProxy):
- no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT }
+ no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, PSBT_OUT_AMOUNT,
+ PSBT_OUT_SCRIPT }
blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script',
'is_change', 'num_our_keys', 'amount', 'script', 'attestation')
@@ -592,8 +593,10 @@ class psbtInputProxy(psbtProxy):
# only part-sigs have a key to be stored.
no_keys = { PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE,
- PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG,
- PSBT_IN_FINAL_SCRIPTWITNESS }
+ PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG,
+ PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX,
+ PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME,
+ PSBT_IN_REQUIRED_HEIGHT_LOCKTIME }
blank_flds = (
'unknown', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script',
@@ -1071,7 +1074,10 @@ def serialize(self, out_fd, is_v2):
class psbtObject(psbtProxy):
"Just? parse and store"
short_values = { PSBT_GLOBAL_TX_MODIFIABLE }
- no_keys = { PSBT_GLOBAL_UNSIGNED_TX }
+ no_keys = { PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_TX_VERSION,
+ PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_INPUT_COUNT,
+ PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_TX_MODIFIABLE,
+ PSBT_GLOBAL_VERSION }
def __init__(self):
super().__init__()
@@ -1154,10 +1160,14 @@ def store(self, kt, key, val):
elif kt == PSBT_GLOBAL_FALLBACK_LOCKTIME:
self.fallback_locktime = unpack("<I", self.get(val))[0]
elif kt == PSBT_GLOBAL_INPUT_COUNT:
- self.num_inputs = deser_compact_size(BytesIO(self.get(val)))
+ raw = self.get(val)
+ self.num_inputs = deser_compact_size(BytesIO(raw))
+ assert raw == ser_compact_size(self.num_inputs), "invalid input count"
self.has_gic = True
elif kt == PSBT_GLOBAL_OUTPUT_COUNT:
- self.num_outputs = deser_compact_size(BytesIO(self.get(val)))
+ raw = self.get(val)
+ self.num_outputs = deser_compact_size(BytesIO(raw))
+ assert raw == ser_compact_size(self.num_outputs), "invalid output count"
self.has_goc = True
elif kt == PSBT_GLOBAL_TX_MODIFIABLE:
# bytes of length 1 (tx modifiable in short_values)
@@ -1506,9 +1516,9 @@ async def validate(self):
# verision is provided in PSBT - take it as given
assert self.version in (0,2)
else:
- # PSBT version is not defined
- # global unsigned tx is only allowed in v0
- self.version = 2 if self.txn is None else 0
+ # PSBTv0 may omit its version, but PSBTv2 must specify version 2.
+ assert self.txn, "v2 requires global version"
+ self.version = 0
self.is_v2 = self.version is not None and self.version >= 2
### testing/test_sign.py
@@ -7,7 +7,13 @@
from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError
from binascii import b2a_hex, a2b_hex
from psbt import (BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT,
- PSBT_GLOBAL_VERSION, PSBT_IN_WITNESS_UTXO, PSBT_OUT_AMOUNT)
+ PSBT_GLOBAL_VERSION, PSBT_GLOBAL_TX_VERSION,
+ PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_INPUT_COUNT,
+ PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_TX_MODIFIABLE,
+ PSBT_IN_WITNESS_UTXO, PSBT_IN_PREVIOUS_TXID,
+ PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE,
+ PSBT_IN_REQUIRED_TIME_LOCKTIME, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME,
+ PSBT_OUT_AMOUNT, PSBT_OUT_SCRIPT)
from io import BytesIO
from pprint import pprint
from decimal import Decimal
@@ -82,6 +88,66 @@ def add_duplicate(psbt):
assert 'PSBT parse failed' in ee.value.args[0]
+
+@pytest.mark.parametrize('scope, ktype, value', [
+ ('global', PSBT_GLOBAL_TX_VERSION, struct.pack('<I', 2)),
+ ('global', PSBT_GLOBAL_FALLBACK_LOCKTIME, struct.pack('<I', 0)),
+ ('global', PSBT_GLOBAL_INPUT_COUNT, b'\x01'),
+ ('global', PSBT_GLOBAL_OUTPUT_COUNT, b'\x01'),
+ ('global', PSBT_GLOBAL_TX_MODIFIABLE, b'\x00'),
+ ('global', PSBT_GLOBAL_VERSION, struct.pack('<I', 2)),
+ ('input', PSBT_IN_PREVIOUS_TXID, bytes(32)),
+ ('input', PSBT_IN_OUTPUT_INDEX, struct.pack('<I', 0)),
+ ('input', PSBT_IN_SEQUENCE, struct.pack('<I', 0xffffffff)),
+ ('input', PSBT_IN_REQUIRED_TIME_LOCKTIME, struct.pack('<I', 500000000)),
+ ('input', PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, struct.pack('<I', 1)),
+ ('output', PSBT_OUT_AMOUNT, struct.pack('<q', 1000)),
+ ('output', PSBT_OUT_SCRIPT, b'\x51'),
+])
+def test_psbt_singleton_rejects_key_data(try_sign, fake_txn, scope, ktype, value):
+ def add_key_data(psbt):
+ target = psbt
+ if scope == 'input':
+ target = psbt.inputs[0]
+ elif scope == 'output':
+ target = psbt.outputs[0]
+ target.unknown = [(bytes([ktype, 0]), value)]
+
+ psbt = fake_txn(1, 1, psbt_v2=True, psbt_hacker=add_key_data)
+
+ with pytest.raises(CCProtoError) as ee:
+ try_sign(psbt, accept=False)
+
+ assert 'PSBT parse failed' in ee.value.args[0]
+
+
+@pytest.mark.parametrize('ktype', [PSBT_GLOBAL_INPUT_COUNT, PSBT_GLOBAL_OUTPUT_COUNT])
+def test_psbt_v2_rejects_non_exact_global_count(try_sign, fake_txn, ktype):
+ def add_trailing_count_byte(psbt):
+ if ktype == PSBT_GLOBAL_INPUT_COUNT:
+ psbt.input_count = None
+ else:
+ psbt.output_count = None
+ psbt.unknown = [(bytes([ktype]), b'\x01\x00')]
+
+ psbt = fake_txn(1, 1, psbt_v2=True, psbt_hacker=add_trailing_count_byte)
+
+ with pytest.raises(CCProtoError) as ee:
+ try_sign(psbt, accept=False)
+
+ assert 'PSBT parse failed' in ee.value.args[0]
+
+
+def test_psbt_v2_requires_global_version(try_sign, fake_txn):
+ psbt = fake_txn(1, 1, psbt_v2=True,
+ psbt_hacker=lambda p: setattr(p, 'version', None))
+
+ with pytest.raises(CCProtoError) as ee:
+ try_sign(psbt, accept=False)
+
+ assert 'Invalid PSBT' in ee.value.args[0]
+
+
@pytest.mark.parametrize('fn', [
'data/2-of-2.psbt',
'data/filled_scriptsig.psbt',Why this scored 59/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.