bugfix: capture single-sig segwit change
What changed, and why it matters
This commit fixes two related security bugs in the COLDCARD hardware wallet's protection against a known 'BIP-143 amount swap' attack. First, the wallet was recording the claimed amount of a segwit input to its secure cache as soon as a transaction was previewed, even if the user later cancelled without signing. A malicious co-signer could therefore 'poison' the cache with a wrong amount and later prevent the owner from spending that coin. Second, single-signature segwit change outputs created by transactions the user signed were not being added to the cache, so an attacker could later trick the wallet into signing a spend that understated the change amount. The patch defers cache writes until after a signature is actually produced, and captures single-sig segwit change amounts at finalize time. It also expands the cache size from 30 to 128 entries.
Users should install a firmware release containing this commit. Until then, be cautious about previewing untrusted PSBTs on the device, and verify change output details manually when signing single-sig segwit transactions. Wallet software that builds PSBTs for COLDCARD should ensure it provides correct witness UTXO amounts and proper change detection.
Security signals we found
Fixes deferred persistence of UTXO amount cache until after successful signing
Fixes missing capture of single-sig segwit change outputs (p2wpkh and p2sh-p2wpkh)
Prevents cache poisoning by cancelled/malicious PSBT previews
Addresses BIP-143 segwit amount-swap attack surface
Adds regression tests for OVC poisoning, multi-input commit, and single-sig change capture
Proof of Reserves explicitly excluded from reading or modifying OVC
Evidence from the diff
The patch changes OutptValueCache in shared/history.py so that first-seen UTXO amounts are no longer persisted during PSBT parsing/verification. Instead, a new commit() method is called from auth.py only after signing succeeds, iterating only inputs that are segwit, have a required_key, have added_sigs, and carry a UTXO. This prevents cancelled previews from poisoning the on-device OVC flash storage. The patch also updates finalize() in shared/psbt.py to record single-sig segwit change outputs (native p2wpkh and p2sh-p2wpkh) in addition to p2wsh/p2sh-p2wsh change, so later understated claims on those outputs raise IncorrectUTXOAmount. The cache depth is raised from 30 to 128 entries. New tests verify the BIP-143 attack still fails, that cancelled PSBTs do not modify OVC, and that single-sig segwit change capture works for both p2wpkh and p2sh-p2wpkh.
Changed components
shared/history.py (OutptValueCache)shared/auth.py (post-signing commit hook)shared/psbt.py (PSBT.finalize change-output detection)testing/test_sign.pytesting/test_bip322.pyInspect captured patch +322 / −80
### releases/Next-ChangeLog.md
@@ -9,6 +9,11 @@ This lists the new changes that have not yet been published in a normal release.
- 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.
+- Enhancement: Retain up to 128 UTXO cache entries across restarts.
+- Bugfix: Cancelled PSBTs no longer persist claimed input amounts to the UTXO
+ cache; amounts are committed only after signing, for inputs actually signed.
+- Bugfix: Cache single-sig segwit change amounts at finalize, so understated
+ input amounts are caught instead of silently trusted.
- Bugfix: Reject foreign inputs from BIP-322 Proof of Reserves, including inputs
disguised with forged key-path metadata or partial signatures.
- Bugfix: Detect and abort transaction signing if a Virtual Disk firmware import
### shared/auth.py
@@ -632,6 +632,13 @@ async def interact(self):
# update SSSP block_h even if SSSP blocks and overridden by CCC
SSSPFeature.update_last_signed(self.psbt)
+ # signing succeeded: record first-seen UTXO amounts only for inputs
+ # that received our signature; parsing itself does not change history
+ # Proof of Reserves must not read or modify this history.
+ if not self.psbt.por322:
+ from history import OutptValueCache
+ OutptValueCache.commit(self.psbt)
+
except FraudulentChangeOutput as exc:
return await self.failure(exc.args[0], title='Change Fraud')
except MemoryError:
### shared/history.py
@@ -2,7 +2,7 @@
#
# history.py - store some history about past transactions and/or outputs they involved
#
-import gc, chains
+import chains
from uhashlib import sha256
from ustruct import pack, unpack
from exceptions import IncorrectUTXOAmount
@@ -18,10 +18,9 @@
# - 8 bytes exact satoshi value => base64 (pad trimmed) => 11 chars
# - stored satoshi value is XOR'ed with LSB from prevout txn hash, which isn't stored
# - result is a 31 character string for each history entry, plus 4 overhead => 35 each
-# - if we store 30 of those it's about 25% of total setting space (Mk3)
+# - 128 entries use about 4.4 KiB, plus the other wallet settings
#
-HISTORY_SAVED = const(30)
-HISTORY_MAX_MEM = const(128)
+HISTORY_DEPTH = const(128)
# length of hashed&encoded key only (base64(15 bytes) => 20)
ENCKEY_LEN = const(20)
@@ -32,28 +31,12 @@ class OutptValueCache:
# - stored as b64 key concatenated w/ int
KEY = 'ovc'
- # we keep extra entries here during the current power-up
- # as defense against using very large txn in the attack
- runtime_cache = []
- _cache_loaded = False
-
@classmethod
def clear(cls):
# user action in danger zone menu
- cls.runtime_cache.clear()
- cls._cache_loaded = True
settings.remove_key(cls.KEY)
settings.save()
- @classmethod
- def load_cache(cls):
- # first time: read saved value, but rest of time; use what's in memory
- if not cls._cache_loaded:
- saved = settings.get(cls.KEY) or []
- cls.runtime_cache.extend(saved)
- cls._cache_loaded = True
-
-
@classmethod
def encode_key(cls, prevout):
# hash up the txid and output number, truncate, and encode as base64
@@ -81,33 +64,37 @@ def decode_value(cls, prevout, text):
return unpack('<Q', val)[0]
@classmethod
- def fetch_amount(cls, prevout):
+ def get_cache(cls):
+ return settings.get(cls.KEY) or []
+
+ @classmethod
+ def fetch_amount(cls, prevout, cache=None):
# Return the amount we expect for this utxo, if we have it, else None
- cls.load_cache()
+ if cache is None:
+ cache = cls.get_cache()
- if not cls.runtime_cache:
+ if not cache:
return None
key = cls.encode_key(prevout)
- for v in cls.runtime_cache:
+ for v in cache:
if v[0:ENCKEY_LEN] == key:
return cls.decode_value(prevout, v[ENCKEY_LEN:])
return None
@classmethod
- def verify_amount(cls, prevout, amount, in_idx):
+ def verify_amount(cls, prevout, amount, in_idx, cache=None):
# check this input either:
- # - not been seen before, in which case, record it
- # - OR: the amount matches exactly, any previously-seend UTXO w/ same outpoint
+ # - not been seen before, in which case it may be recorded after signing
+ # - OR: the amount matches exactly, any previously-seen UTXO w/ same outpoint
# raises IncorrectUTXOAmount with details if it fails, which should abort any signing
- exp = cls.fetch_amount(prevout)
+ exp = cls.fetch_amount(prevout, cache)
if exp is None:
- # new entry, add it
- cls.add(prevout, amount)
+ return False
- elif exp != amount:
+ if exp != amount:
# Found the hacking we are looking for!
ch = chains.current_chain()
exp, units = ch.render_value(exp, True)
@@ -116,28 +103,49 @@ def verify_amount(cls, prevout, amount, in_idx):
raise IncorrectUTXOAmount(in_idx, "Expected %s but PSBT claims %s %s" % (
exp, amount, units))
+ return True
+
+ @classmethod
+ def commit(cls, psbt):
+ # Signing succeeded: record first-seen amounts directly from inputs that
+ # received our signature. Parsing/cancelling a PSBT leaves no OVC state.
+ # - called only after a signature was actually produced (post-approval)
+ # - required_key shows signing intent; added_sigs is evidence from sign_it()
+ # - Proof of Reserves must never read or modify this history
+ if psbt.por322:
+ return
+
+ # copy so a later verification failure leaves settings unchanged
+ cache = list(cls.get_cache())
+ changed = False
+
+ for in_idx, txin in psbt.input_iter():
+ inp = psbt.inputs[in_idx]
+ if inp.is_segwit and inp.added_sigs and inp.required_key and inp.has_utxo():
+ if not cls.verify_amount(txin.prevout, inp.amount, in_idx, cache):
+ # add() serializes prevout immediately, which is important because
+ # PSBTv0 input_iter() reuses and mutates its CTxIn object
+ cls.add(cache, txin.prevout, inp.amount)
+ changed = True
+
+ if changed:
+ settings.set(cls.KEY, cache)
+
@classmethod
- def add(cls, prevout, amount):
- # protect privacy, compress a little, and save it.
+ def add(cls, cache, prevout, amount):
+ # protect privacy, compress a little, and append it.
# - we know it's not yet in our lists
key = cls.encode_key(prevout)
- # memory management: can't store very much, so trim as needed
- depth = HISTORY_SAVED
-
- # also limit in-memory use
- cls.load_cache()
- if len(cls.runtime_cache) >= HISTORY_MAX_MEM:
- del cls.runtime_cache[0]
-
# save new addition
assert len(key) == ENCKEY_LEN
# assert amount > 0
entry = key + cls.encode_value(prevout, amount)
- cls.runtime_cache.append(entry)
+ # evict first so append does not grow a full MicroPython list
+ if len(cache) >= HISTORY_DEPTH:
+ del cache[0]
- # update what we're going to save long-term
- settings.set(cls.KEY, cls.runtime_cache[-depth:])
+ cache.append(entry)
# As we build new transaction, track what we need to capture
new_outpts = []
@@ -156,16 +164,17 @@ def add_segwit_utxos_finalize(txid):
# might not have any change, or they may not be segwit
if not new_outpts: return
- # add it to the cache
- prevout = COutPoint(uint256_from_str(txid), 0)
+ # copy and add all change outputs, then update settings once
+ cache = list(OutptValueCache.get_cache())
+ prevout = COutPoint(uint256_from_str(txid), 0)
for oi, amount in new_outpts:
prevout.n = oi
- OutptValueCache.add(prevout, amount)
+ OutptValueCache.add(cache, prevout, amount)
+ settings.set(OutptValueCache.KEY, cache)
new_outpts.clear()
# shortcut
verify_amount = lambda *a: OutptValueCache.verify_amount(*a)
-
# EOF
### shared/psbt.py
@@ -110,6 +110,14 @@ def _skip_n_objs(fd, n, cls):
return rv
+def is_wrapped_p2wpkh_redeem(rs):
+ # redeem script is a bare v0 p2wpkh witness program nested in p2sh (wrapped segwit)
+ return len(rs) == 22 and rs[0] == 0 and rs[1] == 20
+
+def is_wrapped_p2wsh_redeem(rs):
+ # redeem script is a bare v0 p2wsh witness program nested in p2sh (wrapped segwit)
+ return len(rs) == 34 and rs[0] == 0 and rs[1] == 32
+
def calc_txid(fd, poslen, body_poslen=None):
# Given the (pos,len) of a transaction in a file, return the txid for that txn.
# - doesn't validate data
@@ -474,8 +482,7 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent):
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 \
+ if not is_segwit and is_wrapped_p2wpkh_redeem(redeem_script) and \
txo.scriptPubKey == target_spk:
# it's actually segwit p2wpkh inside p2sh
pkh = redeem_script[2:22]
@@ -513,8 +520,7 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent):
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):
+ is_wrapped_p2wsh_redeem(redeem_script):
# can also check if redeem script hashes to hash160 and compare with scriptPubKey
af = AF_P2WSH_P2SH
@@ -774,10 +780,8 @@ def witness_utxo_is_provably_segwit(self, utxo):
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
+ return (is_wrapped_p2wpkh_redeem(redeem_script) or is_wrapped_p2wsh_redeem(redeem_script)) \
+ 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
@@ -852,8 +856,7 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt, cosign_xfp=None):
self.scriptSig = redeem_script
- if not addr_is_segwit and len(redeem_script) == 22 and \
- redeem_script[0] == 0 and redeem_script[1] == 20:
+ if not addr_is_segwit and is_wrapped_p2wpkh_redeem(redeem_script):
# segwit p2pkh wrapped in p2sh: exactly one key, not multisig.
# psbt creator tells us the key by providing exactly one subpath.
self.addr_fmt = AF_P2WPKH_P2SH
@@ -1876,6 +1879,7 @@ def consider_inputs(self, cosign_xfp=None):
from_wif_store = []
prevouts = set()
foreign_por = False
+ ovc = None if self.por322 else history.OutptValueCache.get_cache()
for i, txi in self.input_iter():
# check for duplicate inputs
@@ -1939,7 +1943,7 @@ def consider_inputs(self, cosign_xfp=None):
# capture that value, since it's supposed to be immutable
# Proof of Reserves PSBT must not modify history
if inp.is_segwit and not self.por322:
- history.verify_amount(txi.prevout, inp.amount, i)
+ history.verify_amount(txi.prevout, inp.amount, i, ovc)
if self.por322 and (i == 0):
# Proof of Reserves 'to_spend' validation
@@ -2646,8 +2650,14 @@ def finalize(self, fd):
fd.write(txo.serialize())
# capture change output amounts (if segwit)
- if self.outputs[out_idx].is_change and self.outputs[out_idx].witness_script:
- history.add_segwit_utxos(out_idx, txo.nValue)
+ # - p2wsh & p2sh-p2wsh change always carries witness_script
+ # - single-sig change usually has keypaths only: detect p2wpkh from
+ # scriptPubKey, and p2sh-p2wpkh from its 22-byte v0 redeem script
+ outp = self.outputs[out_idx]
+ if outp.is_change:
+ rs = self.get(outp.redeem_script) if outp.redeem_script else None
+ if outp.witness_script or txo.is_p2wpkh() or (rs and is_wrapped_p2wpkh_redeem(rs)):
+ history.add_segwit_utxos(out_idx, txo.nValue)
body_end = fd.tell()
### testing/test_bip322.py
@@ -85,6 +85,38 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_
press_cancel()
+def test_bip322_does_not_modify_ovc(bip322_txn, start_sign, end_sign,
+ cap_story, press_select, settings_get,
+ sim_exec, bip322_verify):
+ # Proof of Reserves deliberately skips OVC verification and must not modify
+ # the committed cache when it signs the proof.
+ psbt, _ = bip322_txn([
+ ["p2wpkh", None, None],
+ ["p2wpkh", None, 10000000],
+ ])
+
+ sim_exec("from history import OutptValueCache; "
+ "from glob import settings; "
+ "from serializations import COutPoint; "
+ "OutptValueCache.clear(); "
+ "cache = []; "
+ "OutptValueCache.add(cache, COutPoint(1, 0), 1); "
+ "settings.set(OutptValueCache.KEY, cache)")
+ before = settings_get('ovc')
+ assert len(before) == 1
+
+ start_sign(psbt, finalize=True)
+ title, story = cap_story()
+ assert title == "OK TO SIGN?"
+ assert "Proof of Reserves" in story
+ press_select()
+ signed = end_sign(accept=None)
+ bip322_verify(signed)
+
+ assert settings_get('ovc') == before
+ sim_exec('import history; history.OutptValueCache.clear()')
+
+
@pytest.mark.parametrize("msg, concern", [
("UTF-8: öäüéàè".encode(), "ascii"),
(b"shown\x03hidden", "must be ascii printable"),
### testing/test_sign.py
@@ -17,7 +17,7 @@
from helpers import xfp2str, seconds2human_readable, hash160
from msg import verify_message
from bip32 import BIP32Node
-from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP, simulator_fixed_xfp
+from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP, simulator_fixed_xfp, simulator_fixed_tprv
from txn import *
from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint
from ckcc_protocol.constants import STXN_VISUALIZE, STXN_SIGNED
@@ -1202,30 +1202,34 @@ def test_change_troublesome(dev, start_sign, cap_story, try_path, expect, sim_ro
assert parse_change_back(story) == (Decimal('1.09997082'), ['mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo'])
-def test_bip143_attack(try_sign, sim_exec, set_xfp, settings_set, settings_get):
+def test_bip143_attack(try_sign, sim_exec, fake_txn, settings_get):
# cleanup prev runs
sim_exec('import history; history.OutptValueCache.clear()')
- # hand-modified transactions from Andrew Chow
- set_xfp('D1A226A9')
- mod1 = b64decode(open('data/b143a_mod1.psbt').read())
- mod2 = b64decode(open('data/b143a_mod2.psbt').read())
+ # Same two-input amount swap as the original Andrew Chow fixtures, but use
+ # simulator-owned keys so the first claim can establish history by signing.
+ mod1 = fake_txn(2, 1, segwit_in=True,
+ invals=[500001000, 2000000000], outvals=[2500000000])
+ mod2 = _same_prevout_variant(mod1, [1500000000, 1000001000])
- orig, result = try_sign(mod1, accept=False)
+ try_sign(mod1, accept=False)
+ try_sign(mod2, accept=False)
+ assert not settings_get('ovc')
- # after seeing first one, should raise an error on second one
+ # A cancelled request does not establish history. Once the first claim is
+ # signed, presenting the conflicting claim must fail.
+ try_sign(mod1, accept=True)
with pytest.raises(CCProtoError) as ee:
- orig, result = try_sign(mod2, accept=False)
+ try_sign(mod2, accept=False)
assert 'but PSBT claims 15 XTN' in str(ee), ee
- assert len(settings_get('ovc')) == 2
sim_exec('import history; history.OutptValueCache.clear()')
# try in opposite order, should also trigger
- orig, result = try_sign(mod2, accept=False)
+ try_sign(mod2, accept=True)
with pytest.raises(CCProtoError) as ee:
- orig, result = try_sign(mod1, accept=False)
+ try_sign(mod1, accept=False)
assert 'but PSBT claims' in str(ee), ee
assert 'Expected 15 but' in str(ee)
@@ -1279,10 +1283,9 @@ def spend_outputs(funding_psbt, finalized_txn, tweaker=None):
return nn, raw
@pytest.fixture
-def hist_count(sim_exec):
+def hist_count(settings_get):
def doit():
- return int(sim_exec(
- 'import history; RV.write(str(len(history.OutptValueCache.runtime_cache)));'))
+ return len(settings_get('ovc') or [])
return doit
@pytest.fixture
@@ -1325,19 +1328,21 @@ def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, set
press_cancel()
press_cancel()
- assert hist_count() in {128, hist_b4+num_utxo+num_inp_utxo}
+ expect_history = min(128, hist_b4+num_utxo+1+num_inp_utxo)
+ assert hist_count() == expect_history
t = CTransaction()
t.deserialize(BytesIO(txn))
assert t.txid().hex() == txid
- # expect all of new "change outputs" to be recorded (none of the non-segwit change tho)
+ # expect all of new segwit "change outputs" to be recorded: num_utxo p2wpkh plus
+ # the one p2sh-p2wpkh (none of the non-segwit p2pkh change tho),
# plus the one input we "revealed"
after1 = settings_get('ovc')
- assert len(after1) == min(30, num_utxo + num_inp_utxo)
+ assert len(after1) == min(128, num_utxo + 1 + num_inp_utxo)
all_utxo = hist_count()
- assert all_utxo == hist_b4+num_utxo+num_inp_utxo
+ assert all_utxo == expect_history
# build a new PSBT based on those change outputs
psbt2, raw = spend_outputs(psbt, txn)
with open(f'{sim_root_dir}/debug/spend_outs.psbt', 'wb') as f:
@@ -1365,6 +1370,180 @@ def value_tweak(spendables):
assert 'but PSBT claims' in str(ee), ee
+def _same_prevout_variant(psbt_bytes, claim_amounts):
+ # return a copy of a segwit PSBT spending the SAME prevout, but with the
+ # inputs' witness_utxo amounts replaced by claim_amounts (lies).
+ # fake_txn bakes input_amount into the funding txid, so two fake_txn calls
+ # with different amounts do NOT share a prevout -- patch the claim instead.
+ ps = BasicPSBT().parse(psbt_bytes)
+ assert len(claim_amounts) == len(ps.inputs)
+ for inp, claim_amount in zip(ps.inputs, claim_amounts):
+ utxo = CTxOut()
+ utxo.deserialize(BytesIO(inp.witness_utxo))
+ utxo.nValue = claim_amount
+ inp.witness_utxo = utxo.serialize()
+
+ with BytesIO() as fd:
+ ps.serialize(fd)
+ return fd.getvalue()
+
+
+def test_ovc_not_poisoned_pre_approval(try_sign, fake_txn, settings_get, sim_exec, hist_count):
+ # A parsed-but-cancelled PSBT must NOT persist first-seen UTXO amounts to flash
+ # (anti-exfiltration cache); only an actual signature may commit them. Otherwise a
+ # hostile cosigner can poison a fresh prevout with a wrong amount (no approval needed)
+ # and DoS the honest spend of that UTXO.
+ #
+ # The segwit input here is foreign (its prevout was never signed by this device), so
+ # verify_amount sees it as first-seen. The PSBT parses (device keys), but the user
+ # cancels -> no signature -> nothing may be committed.
+ sim_exec('import history; history.OutptValueCache.clear()')
+ assert hist_count() == 0
+ assert not settings_get('ovc')
+
+ # attacker previews a PSBT with a WRONG input amount; user cancels (X).
+ honest = fake_txn(1, 1, segwit_in=True, input_amount=int(0.49995 * 1E8))
+ evil = _same_prevout_variant(honest, [int(0.50095 * 1E8)])
+ try_sign(evil, accept=False)
+ assert not settings_get('ovc'), "cancelled preview poisoned the persisted cache"
+ assert hist_count() == 0
+
+ # The cancelled request left no OVC state, so the honest request is first-seen
+ # and signs cleanly without requiring a reboot.
+ try_sign(honest, accept=True, finalize=True)
+
+ # and only now is the prevout's amount committed (post-signature)
+ assert settings_get('ovc'), "signature did not commit the approved UTXO amount"
+
+
+def test_ovc_commit_only_signed_inputs(try_sign, fake_txn, settings_get, sim_exec, hist_count):
+ # A cancelled PSBT must not affect a later, unrelated signature.
+ sim_exec('import history; history.OutptValueCache.clear()')
+ assert hist_count() == 0
+ assert not settings_get('ovc')
+
+ # hostile preview of UTXO 0 with a WRONG amount; user cancels
+ honest = fake_txn(1, 1, segwit_in=True, input_amount=int(0.49995 * 1E8))
+ evil = _same_prevout_variant(honest, [int(0.50095 * 1E8)])
+ try_sign(evil, accept=False)
+ assert not settings_get('ovc')
+
+ # victim then signs an unrelated transaction (different inputs, 2 ins/1 out
+ # so there is no change output to capture either)
+ try_sign(fake_txn(2, 1, segwit_in=True), accept=True, finalize=True)
+ after = settings_get('ovc') or []
+ assert len(after) == 2, "only the signed txn's two inputs may be committed"
+
+ # the poisoned claim for the SAME prevout was not committed, so the honest
+ # spend is first-seen and signs with the correct amount
+ try_sign(honest, accept=True, finalize=True)
+ assert len(settings_get('ovc')) == 3, "only correct amounts may be committed"
+
+
+def test_ovc_multi_input_amounts(try_sign, fake_txn, settings_get, sim_exec):
+ # PSBTv0 input_iter reuses its CTxIn object. commit() must serialize each
+ # prevout before advancing, so unequal amounts are recorded under the right keys.
+ sim_exec('import history; history.OutptValueCache.clear()')
+ psbt = fake_txn(2, 1, segwit_in=True,
+ invals=[100000000, 125000000], outvals=[224990000])
+
+ try_sign(psbt, accept=True, finalize=True)
+ assert len(settings_get('ovc')) == 2
+
+ # A second pass checks both persisted amounts against the same prevouts.
+ try_sign(psbt, accept=True, finalize=True)
+ assert len(settings_get('ovc')) == 2
+
+
+@pytest.mark.parametrize('in_style', ['p2wpkh', 'p2wpkh-p2sh'])
+def test_ovc_singlesig_change_capture(in_style, try_sign, fake_txn, settings_get,
+ sim_exec, hist_count, press_cancel,
+ txid_from_export_prompt):
+ # Regression for first-seen segwit amount trust (coldcard-internal#1126):
+ # single-sig segwit change (p2wpkh and p2sh-p2wpkh) produced by a txn we sign
+ # must be captured into the UTXO value cache at finalize time, so a later
+ # understated amount claim on that UTXO is rejected with IncorrectUTXOAmount
+ sim_exec('import history; history.OutptValueCache.clear()')
+ assert hist_count() == 0
+
+ # non-change output goes to a random fake destination (not ours), so the
+ # change output is the only UTXO this txn leaves to this device
+ dest = fake_dest_addr('p2wpkh')
+
+ def pay_away(ps):
+ ps.outputs[0].bip32_paths = {}
+
+ psbt = fake_txn(1, 2, segwit_in=True, wrapped=(in_style == 'p2wpkh-p2sh'),
+ change_outputs=[1], outstyles=['p2wpkh', in_style],
+ fee=10000, psbt_hacker=pay_away)
+ psbt = BasicPSBT().parse(psbt)
+ psbt.outputs[0].script = dest
+ with BytesIO() as fd:
+ psbt.serialize(fd)
+ raw = fd.getvalue()
+
+ _, tx = try_sign(raw, accept=True, finalize=True, exit_export_loop=False)
+ txid = txid_from_export_prompt()
+ press_cancel()
+ press_cancel()
+
+ # cache now holds: funding input + our change output (index 1)
+ assert len(settings_get('ovc')) == 2, settings_get('ovc')
+
+ t = CTransaction()
+ t.deserialize(BytesIO(tx))
+ assert t.txid().hex() == txid
+ change_out = t.vout[1]
+ change_val = change_out.nValue
+
+ # find the key to spend the change: same path make_change_addr used,
+ # 12/34/N over the simulator seed. For wrapped (p2sh-p2wpkh) the output
+ # hash is hash160(redeem) so compare against the redeem hash.
+ mk = BIP32Node.from_wallet_key(simulator_fixed_tprv)
+ wrapped = (in_style == 'p2wpkh-p2sh')
+ target = change_out.scriptPubKey[2:22]
+ found = None
+ for n in range(1001):
+ sk = mk.subkey_for_path('12/34/%d' % n)
+ pkh = sk.hash160()
+ if wrapped:
+ pkh = hash160(bytes([0, 20]) + pkh)
+ if pkh == target:
+ found = n, sk
+ break
+ assert found, "change key not found"
+ n, subkey = found
+
+ # now spend that change UTXO, but LIE about its amount (understated) -- it
+ # must abort with "but PSBT claims" (IncorrectUTXOAmount); an honest claim
+ # (the exact cached value) must still sign. That verification is the point.
+ def make_spend(amount):
+ sp = BasicPSBT()
+ sp.inputs = [BasicPSBTInput(idx=0)]
+ sp.outputs = [BasicPSBTOutput(idx=0)]
+ sp.inputs[0].bip32_paths[subkey.sec()] = \
+ mk.fingerprint() + struct.pack('<III', 12, 34, n)
+ if in_style == 'p2wpkh-p2sh':
+ sp.inputs[0].redeem_script = psbt.outputs[1].redeem_script
+ sp.inputs[0].witness_utxo = CTxOut(amount, change_out.scriptPubKey).serialize()
+ stxn = CTransaction()
+ stxn.nVersion = 2
+ stxn.vin = [CTxIn(COutPoint(t.sha256, 1), nSequence=0xffffffff)]
+ stxn.vout = [CTxOut(amount - 10000, fake_dest_addr('p2wpkh'))]
+ sp.txn = stxn.serialize_with_witness()
+ with BytesIO() as fd:
+ sp.serialize(fd)
+ return fd.getvalue()
+
+ with pytest.raises(CCProtoError) as ee:
+ try_sign(make_spend(change_val - 1000000), accept=True)
+
+ assert 'but PSBT claims' in str(ee), ee
+
+ # honest spend of the cached change still works
+ try_sign(make_spend(change_val), accept=True, finalize=True)
+
+
@pytest.mark.parametrize('segwit', [False, True])
@pytest.mark.parametrize('num_ins', [1, 17])
def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, cap_story,Why this scored 74/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.