What changed, and why it matters
This commit fixes a bug in the COLDCARD hardware wallet's staged PSBT (Partially Signed Bitcoin Transaction) signing process. Previously, the wallet only re-checked the transaction hash if something was written to the lower 'input' staging region of its external memory. Writes to the upper 'output' staging region did not trigger a re-check, meaning a malicious or buggy process could potentially modify transaction data there without detection. The fix makes the write counter cover both regions, ensures oversized transaction files are rejected before staging, and updates the signing guard so that a write to the inactive region is treated as a new baseline rather than a false alarm. In short, it closes a gap where a transaction could be tampered with after user review but before signing.
Treat this as a security-relevant bugfix. Users should upgrade to firmware containing this commit. Developers should review any other paths that stage transaction data into PSRAM to ensure the write counter covers all mutable regions and that size limits are enforced before staging.
Security signals we found
fail-closed on transaction mutation
post-review tamper detection bypass possible before fix
staging memory write counter coverage gap
oversized transaction file rejection added
base64-encoded size estimate vs decoded size handling
PSBT signing integrity hardening
Evidence from the diff
The patch hardens staged PSBT signing in three ways. First, shared/psram.py now increments txn_write_count for writes anywhere below 2*MAX_TXN_LEN, covering both the TXN input staging region and the TXN output staging region. Second, shared/auth.py’s sign_psbt_file() now rejects files whose decoded size exceeds MAX_TXN_LEN before staging, instead of relying on the SFFile max_size and asserting later; it also handles base64-encoded files whose encoded size estimate exceeds MAX_TXN_LEN but whose decoded payload fits. Third, the pre-signing fast-path in auth.py now distinguishes ‘active PSBT changed’ (hash mismatch -> fail closed) from ‘inactive staging region was touched’ (hash matches -> update parsed_write_count baseline). Tests are added for inactive-region writes, write-counter coverage, oversized virtual-disk PSBT rejection, and base64-wrapped size handling.
Changed components
shared/auth.pyshared/psram.pyshared/teleport.pytesting/test_sign.pytesting/test_vdisk.pyInspect captured patch +162 / −29
### shared/auth.py
@@ -600,13 +600,17 @@ async def interact(self):
# - fast path: nothing wrote to the TXN region since we hashed it,
# so there is no need to re-hash in that (common) case
from glob import PSRAM
- if (PSRAM.txn_write_count != self.parsed_write_count) and \
- (psram_sha256(self.offset, self.psbt_len) != self.parsed_sha):
- # fail closed: wipe the txn, so no signature over modified data
- psram_wipe(self.offset, self.psbt_len)
- del self.psbt
- gc.collect()
- return await self.failure("Transaction modified")
+ if PSRAM.txn_write_count != self.parsed_write_count:
+ if psram_sha256(self.offset, self.psbt_len) != self.parsed_sha:
+ # fail closed: wipe the txn, so no signature over modified data
+ psram_wipe(self.offset, self.psbt_len)
+ del self.psbt
+ gc.collect()
+ return await self.failure("Transaction modified")
+
+ # A writer touched the other TXN staging region. The active PSBT is
+ # unchanged, so make this successful re-check the new baseline.
+ self.parsed_write_count = PSRAM.txn_write_count
# do the actual signing.
try:
@@ -1114,25 +1118,43 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=Fal
decoder, output_encoder, psbt_len = psbt_encoding_taster(taste, psbt_len)
total = 0
- with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out:
- while 1:
- n = fd.readinto(tmp_buf)
- if not n: break
-
- if n == len(tmp_buf):
- abuf = tmp_buf
- else:
- abuf = memoryview(tmp_buf)[0:n]
-
- if not decoder:
- out.write(abuf)
- total += n
- else:
- for here in decoder.more(abuf):
- out.write(here)
- total += len(here)
+ # Binary length is exact, so reject it without staging. Encoded
+ # lengths are estimates and must be checked as bytes are decoded.
+ too_big = not decoder and psbt_len > MAX_TXN_LEN
+ if not too_big:
+ with SFFile(TXN_INPUT_OFFSET, max_size=MAX_TXN_LEN) as out:
+ while 1:
+ n = fd.readinto(tmp_buf)
+ if not n: break
+
+ if n == len(tmp_buf):
+ abuf = tmp_buf
+ else:
+ abuf = memoryview(tmp_buf)[0:n]
- dis.progress_sofar(total, psbt_len)
+ if not decoder:
+ out.write(abuf)
+ total += n
+ else:
+ for here in decoder.more(abuf):
+ if total + len(here) > MAX_TXN_LEN:
+ too_big = True
+ break
+ out.write(here)
+ total += len(here)
+
+ if too_big:
+ break
+
+ dis.progress_sofar(total, psbt_len)
+
+ if too_big:
+ size = " (%d bytes)" % psbt_len if not decoder else ""
+ await ux_show_story(
+ "That transaction file is too big%s. "
+ "Maximum supported is %d bytes." % (size, MAX_TXN_LEN),
+ title='Sorry')
+ return
# might have been whitespace inflating initial estimate of PSBT size
assert total <= psbt_len
### shared/psram.py
@@ -10,8 +10,9 @@ class PSRAMWrapper:
base = 0x9000_0000 # OCTOSPI1
length = 0x40_0000 # 4 meg (lower half)
- # bumped on every write into the TXN input region (below MAX_TXN_LEN);
- # used to detect post-review mutation of the transaction being signed
+ # bumped on every write into the TXN staging regions (input below
+ # MAX_TXN_LEN, output at/above it); used to detect post-review
+ # mutation of the transaction being signed
txn_write_count = 0
def __init__(self):
@@ -27,7 +28,8 @@ def write_at(self, offset, ln):
assert ln % 4 == 0, ln
assert offset + ln <= self.length, (offset+ln)
- if offset < version.MAX_TXN_LEN:
+ if offset < 2 * version.MAX_TXN_LEN:
+ # covers both TXN staging regions (input and output)
self.txn_write_count += 1
return memoryview(self._wr)[offset:offset+ln]
### shared/teleport.py
@@ -764,6 +764,8 @@ async def kt_send_file_psbt(*a):
# read into PSRAM from wherever
psbt_len = await sign_psbt_file(input_psbt, just_read=True, **picked)
+ if psbt_len is None:
+ return
dis.fullscreen("Validating...")
try:
### testing/test_sign.py
@@ -4228,4 +4228,42 @@ def test_psbt_mutation_before_signing(dev, fake_txn, start_sign, cap_story,
assert 'Transaction modified' in story
press_cancel()
+def test_inactive_psram_region_write_before_signing(fake_txn, start_sign, end_sign,
+ cap_story, sim_exec):
+ # A write to the inactive staging half must trigger a re-hash without
+ # tripping the post-check counter assertion when the active PSBT is intact.
+ in_psbt = fake_txn(2, 2, segwit_in=True)
+ start_sign(in_psbt, finalize=True)
+
+ for _ in range(100):
+ title, story = cap_story()
+ if title == 'OK TO SEND?':
+ break
+ time.sleep(.1)
+ else:
+ raise pytest.fail('no approval screen')
+
+ sim_exec("from glob import PSRAM; import version; "
+ "PSRAM.write(version.MAX_TXN_LEN, bytes(100))")
+
+ end_sign(accept=True, finalize=True)
+
+def test_psram_write_counter_covers_txn_output_region(sim_exec, sim_eval):
+ # any write into either TXN staging region of PSRAM (input below
+ # MAX_TXN_LEN, output at/above it) must bump the write counter that
+ # gates the pre-signing digest re-check -- otherwise a mutation
+ # staged in the output region (teleport re-sign path) is invisible
+ # to the fast path and never re-hashed
+ for _ in range(50):
+ if sim_eval("(__import__('glob').PSRAM is not None)") == 'True':
+ break
+ time.sleep(.2)
+ else:
+ raise pytest.fail('PSRAM not initialized')
+
+ start = int(sim_eval("__import__('glob').PSRAM.txn_write_count"))
+ sim_exec("from glob import PSRAM; import version; PSRAM.write(version.MAX_TXN_LEN, bytes(100))")
+ sim_exec("from glob import PSRAM; PSRAM.write(3 * 1024 * 1024, bytes(100))")
+ assert int(sim_eval("__import__('glob').PSRAM.txn_write_count")) == start + 2
+
# EOF
### testing/test_vdisk.py
@@ -8,7 +8,7 @@
import ndef
from hashlib import sha256
from txn import *
-from base64 import b64encode, b64decode
+from base64 import b64encode, b64decode, encodebytes
def test_vd_basics(dev, virtdisk_path, is_simulator):
@@ -212,6 +212,75 @@ def hack(psbt):
else:
assert _txn == txn
+def test_virtdisk_oversized_psbt_rejected(press_select, virtdisk_path, cap_story, virtdisk_wipe,
+ press_cancel, goto_home, sd_cards_eject,
+ settings_set, sim_exec):
+ # files larger than MAX_TXN_LEN must be refused before staging into PSRAM,
+ # not staged past the TXN input region nor crashed on
+ max_txn_len = 2*1024*1024 # MAX_TXN_LEN_MK4
+ settings_set('vidsk', 2) # enable + auto-consume
+ sim_exec("import glob, vdisk\nif not glob.VD: vdisk.VirtDisk()")
+ sd_cards_eject()
+ virtdisk_wipe()
+ time.sleep(.4) # let the vdisk monitor settle
+
+ goto_home()
+ with open(virtdisk_path('too-big.psbt'), 'wb') as f:
+ f.write(b'psbt\xff' + bytes(max_txn_len))
+
+ for _ in range(50):
+ title, story = cap_story()
+ if title == 'Sorry':
+ break
+ time.sleep(.2)
+ else:
+ raise pytest.fail('no rejection story')
+
+ assert 'too big' in story
+ press_cancel()
+
+def test_virtdisk_wrapped_base64_uses_decoded_size(fake_txn, virtdisk_path, cap_story,
+ virtdisk_wipe, press_cancel, goto_home,
+ sd_cards_eject, settings_set, sim_exec):
+ # Whitespace makes the encoded-size estimate exceed MAX_TXN_LEN, although
+ # the decoded PSBT still fits exactly in the input staging region.
+ max_txn_len = 2*1024*1024 # MAX_TXN_LEN_MK4
+ target_len = max_txn_len
+ psbt = BasicPSBT().parse(fake_txn(1, 1, segwit_in=True))
+ padding = 0
+ for _ in range(3):
+ psbt.unknown[b'\xfcsize-check'] = bytes(padding)
+ raw = psbt.as_bytes()
+ if len(raw) == target_len:
+ break
+ padding += target_len - len(raw)
+ assert len(raw) == target_len
+
+ encoded = encodebytes(raw)
+ assert (len(encoded) * 3 // 4) + 10 > max_txn_len
+ assert len(encoded) <= 2 * max_txn_len # MAX_UPLOAD_LEN_MK4
+
+ settings_set('vidsk', 2) # enable + auto-consume
+ sim_exec("import glob, vdisk\nif not glob.VD: vdisk.VirtDisk()")
+ sd_cards_eject()
+ virtdisk_wipe()
+ time.sleep(.4) # let the vdisk monitor settle
+
+ goto_home()
+ with open(virtdisk_path('wrapped.psbt'), 'wb') as f:
+ f.write(encoded)
+
+ for _ in range(150):
+ title, story = cap_story()
+ if title in {'OK TO SEND?', 'Sorry', 'Failure'}:
+ break
+ time.sleep(.2)
+ else:
+ raise pytest.fail('no signing story')
+
+ assert title == 'OK TO SEND?', story
+ press_cancel()
+
if 0:
@pytest.mark.parametrize('num_outs', [ 1, 20, 250])
def test_virtdisk_after(num_outs, fake_txn, try_sign, nfc_read, need_keypress, cap_story):Why this scored 68/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.