bugfix: Reject duplicate singleton PSBT keys
What changed, and why it matters
This update fixes a bug in how the COLDCARD wallet reads PSBT files (the standard format for passing Bitcoin transactions between devices). Previously, if a PSBT contained the same special 'singleton' key twice in the same section, the device might silently use the second value. Now it immediately rejects the file. Duplicate singleton keys could let a malicious transaction tool trick the wallet into using one value while a human reviewer sees another, potentially causing the wrong amount or destination to be signed.
Treat as a security-hardening bugfix and include in the next firmware release. Review whether duplicate detection should also cover non-singleton keys (keys with extra keydata) to fully align with BIP-174 key uniqueness requirements. Ensure the new test cases pass on all supported hardware variants.
Security signals we found
PSBT parser now rejects duplicate singleton keys
New test cases for duplicate keys in global/input/output maps
Changelog labels the change as a bugfix
Fix is narrow: only one-byte singleton keys are deduplicated
Evidence from the diff
The patch adds duplicate-key detection for PSBT keys whose keydata is exactly one byte long (singleton keys) during parsing in shared/psbt.py. It maintains a bitmask, seen_singletons, and asserts that no singleton key type (kt) is repeated within a map. Tests are added for global, input, and output scopes using PSBT_GLOBAL_VERSION, PSBT_IN_WITNESS_UTXO, and PSBT_OUT_AMOUNT as duplicate singleton examples. The fix is partial: it only catches duplicates among one-byte singleton keys, not duplicate entries for keys that carry additional keydata (e.g., duplicate xpubs with the same keydata).
Changed components
shared/psbt.py PSBT parserPSBT global/input/output map parsingCOLDCARD transaction signing flowInspect captured patch +30 / −1
### releases/Next-ChangeLog.md
@@ -6,6 +6,7 @@ This lists the new changes that have not yet been published in a normal release.
- Enhancement: Warn when a transaction's block-height `nLockTime` is more than
ten years beyond the Bitcoin block height known to the firmware.
+- Bugfix: Reject duplicate singleton keys in PSBT maps
- Bugfix: Add a block-height reset to Single-Signer Spending Policy's
**Last Violation** screen after policy bypass, matching CCC.
- Bugfix: Reject foreign inputs from BIP-322 Proof of Reserves, including inputs
### shared/psbt.py
@@ -214,6 +214,7 @@ def __getattr__(self, nm):
def parse(self, fd):
self.fd = fd
+ seen_singletons = 0
while 1:
ks = deser_compact_size(fd)
@@ -226,6 +227,11 @@ def parse(self, fd):
kt = key[0]
+ if len(key) == 1:
+ mask = 1 << kt
+ assert not (seen_singletons & mask), 'duplicate key'
+ seen_singletons |= mask
+
if kt in self.no_keys:
assert len(key) == 1 # not expecting key
### testing/test_sign.py
@@ -6,7 +6,8 @@
import time, pytest, os, random, pdb, struct, base64, binascii, itertools, datetime
from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError
from binascii import b2a_hex, a2b_hex
-from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT
+from psbt import (BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT,
+ PSBT_GLOBAL_VERSION, PSBT_IN_WITNESS_UTXO, PSBT_OUT_AMOUNT)
from io import BytesIO
from pprint import pprint
from decimal import Decimal
@@ -60,6 +61,27 @@ def test_psbt_parse_fails(try_sign, fn):
msg = ee.value.args[0]
assert ('PSBT parse failed' in msg) or ('Invalid PSBT' in msg)
+@pytest.mark.parametrize('scope', ['global', 'input', 'output'])
+def test_psbt_duplicate_singleton_key(try_sign, fake_txn, scope):
+ def add_duplicate(psbt):
+ if scope == 'global':
+ psbt.unknown = [
+ (bytes([PSBT_GLOBAL_VERSION]), struct.pack('<I', psbt.version))
+ ]
+ elif scope == 'input':
+ inp = psbt.inputs[0]
+ inp.unknown = [(bytes([PSBT_IN_WITNESS_UTXO]), inp.witness_utxo)]
+ else:
+ out = psbt.outputs[0]
+ out.unknown = [(bytes([PSBT_OUT_AMOUNT]), struct.pack('<q', out.amount))]
+
+ psbt = fake_txn(1, 1, psbt_v2=True, segwit_in=True, psbt_hacker=add_duplicate)
+
+ with pytest.raises(CCProtoError) as ee:
+ try_sign(psbt, accept=False)
+
+ assert 'PSBT parse failed' in ee.value.args[0]
+
@pytest.mark.parametrize('fn', [
'data/2-of-2.psbt',
'data/filled_scriptsig.psbt',Why this scored 66/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.