What changed, and why it matters
This commit simplifies how the COLDCARD firmware tracks writes to its external PSRAM memory chip. Previously, the firmware kept two separate counters: one for transaction (TXN) data and one for firmware upgrade images. The change merges them into a single counter that increments on any PSRAM write. The goal is to make it harder for an attacker to silently modify data after the user has reviewed it, by detecting any later write to PSRAM. It is a defensive hardening change rather than a fix for a known active attack, and the commit message does not describe a specific vulnerability.
Treat as a hardening improvement. Review whether the unified counter interacts correctly with all PSRAM consumers (e.g., HSM policy, NFC, QR, backup data) to ensure no legitimate write path inadvertently invalidates an unrelated approval snapshot. Continue monitoring for follow-up commits that address any bypass scenarios not covered by this change.
Security signals we found
Defensive integrity check for staged data before cryptographic signing
Single global write counter reduces risk of inconsistent or bypassed per-region counters
Failure-closed behavior: wipes transaction and aborts if staged data changed
Removes conditional counter increment based on offset/MAX_TXN_LEN, closing potential bypass where writes outside expected regions were tracked separately
No explicit bug or CVE described in commit message or diff
Evidence from the diff
The patch replaces two per-purpose write counters (txn_write_count and upgrade_write_count) in shared/psram.py with one global psram_write_count incremented on every PSRAM write. Callers in shared/auth.py (transaction signing) and shared/vdisk.py (firmware import via virtual disk) are updated to use the unified counter. The signing flow already snapshots the counter after parsing and re-checks it before signing; if it changed, it re-hashes the staged PSBT and fails closed if the hash differs. The firmware-upgrade flow asserts the counter is unchanged after user approval. Tests are updated to reference the new counter name. The change broadens the detection surface: any PSRAM write, not just one in the TXN regions, now invalidates the snapshot.
Changed components
shared/psram.py - PSRAM wrapper and write countershared/auth.py - transaction signing integrity checkshared/vdisk.py - firmware import via virtual diskunix/variant/sim_psram.py - simulator PSRAM wrapperInspect captured patch +19 / −31
### shared/auth.py
@@ -391,7 +391,7 @@ async def interact(self):
# - they are re-read from live PSRAM during display, signing & finalization,
# and a USB host could rewrite them while we wait for approval
from glob import PSRAM
- self.parsed_write_count = PSRAM.txn_write_count
+ self.parsed_write_count = PSRAM.psram_write_count
self.parsed_sha = psram_sha256(self.offset, self.psbt_len)
if self.psbt_sha is not None and self.psbt_sha != self.parsed_sha:
del self.psbt
@@ -601,20 +601,20 @@ async def interact(self):
# the parsed bytes must be unchanged since parse/approval; covers all
# input methods and the HSM auto-approval path, as both end up here
- # - fast path: nothing wrote to the TXN region since we hashed it,
+ # - fast path: nothing wrote to PSRAM 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:
+ if PSRAM.psram_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
+ # A writer touched PSRAM outside the active PSBT. The active PSBT is
# unchanged, so make this successful re-check the new baseline.
- self.parsed_write_count = PSRAM.txn_write_count
+ self.parsed_write_count = PSRAM.psram_write_count
# do the actual signing.
try:
@@ -643,8 +643,8 @@ async def interact(self):
# tripwire: no writer could have run since the re-check above, as
# there is no await between it and signing (single-threaded asyncio),
# so this cannot trigger today - it fails loudly if a future change
- # adds an await or a new TXN-region writer on this path
- assert PSRAM.txn_write_count == self.parsed_write_count
+ # adds an await or a new PSRAM writer on this path
+ assert PSRAM.psram_write_count == self.parsed_write_count
try:
await done_signing(self.psbt, self, self.input_method,
@@ -1568,7 +1568,7 @@ def __init__(self, hdr, length, hdr_check=False, psram_offset=None):
self.length = length
self.hdr_check = hdr_check
self.psram_offset = psram_offset
- self.upgrade_write_count = glob.PSRAM.upgrade_write_count
+ self.psram_write_count = glob.PSRAM.psram_write_count
async def interact(self):
from version import decode_firmware_header
@@ -1602,7 +1602,7 @@ async def interact(self):
ch = await ux_show_story(msg)
if ch == 'y':
- assert glob.PSRAM.upgrade_write_count == self.upgrade_write_count
+ assert glob.PSRAM.psram_write_count == self.psram_write_count
# Accepted:
# - write final file header, so bootloader will see it
### shared/psram.py
@@ -2,24 +2,16 @@
#
# psram.py -- access PSRAM chip on Mk4
#
-import version, uctypes
+import uctypes
# already started and memory mapped by bootrom.
class PSRAMWrapper:
base = 0x9000_0000 # OCTOSPI1
length = 0x40_0000 # 4 meg (lower half)
- # 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
-
- # kept separate from transaction signing: firmware approval captures this
- # value and aborts if its staged image is overwritten
- upgrade_write_count = 0
-
def __init__(self):
+ self.psram_write_count = 0
self._wr = uctypes.bytearray_at(self.base, self.length)
def read_at(self, offset, ln):
@@ -32,10 +24,7 @@ def write_at(self, offset, ln):
assert ln % 4 == 0, ln
assert offset + ln <= self.length, (offset+ln)
- if offset < 2 * version.MAX_TXN_LEN:
- # covers both TXN staging regions (input and output)
- self.txn_write_count += 1
- self.upgrade_write_count += 1
+ self.psram_write_count += 1
return memoryview(self._wr)[offset:offset+ln]
### shared/vdisk.py
@@ -107,10 +107,7 @@ def import_file(self, filename, sz):
# through the PSRAM wrapper — record it, and revoke any lease of
# those staged bytes, so post-review tampering can't hide
glob.ALLOWED_DOWNLOAD = None
- glob.PSRAM.txn_write_count += 1
-
- # native copy bypasses PSRAMWrapper.write_at()
- glob.PSRAM.upgrade_write_count += 1
+ glob.PSRAM.psram_write_count += 1
# I could not resist doing this in C... since we already have the
# data in memory, why mess around with file concepts?
### testing/test_sign.py
@@ -4289,9 +4289,9 @@ def test_psram_write_counter_covers_txn_output_region(sim_exec, sim_eval):
else:
raise pytest.fail('PSRAM not initialized')
- start = int(sim_eval("__import__('glob').PSRAM.txn_write_count"))
+ start = int(sim_eval("__import__('glob').PSRAM.psram_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
+ assert int(sim_eval("__import__('glob').PSRAM.psram_write_count")) == start + 2
# EOF
### testing/test_vdisk.py
@@ -229,15 +229,15 @@ def test_virtdisk_import_invalidates_pending_psbt(
else:
pytest.fail('no approval screen')
- before = int(sim_eval("__import__('glob').PSRAM.txn_write_count"))
+ before = int(sim_eval("__import__('glob').PSRAM.psram_write_count"))
# A firmware import uses the native PSRAM copy and overwrites the pending
# transaction before its own authorization attempt is rejected as busy.
with open(virtdisk_path('replacement.dfu'), 'wb') as f:
f.write(bytes(0x50000))
for _ in range(50):
- if int(sim_eval("__import__('glob').PSRAM.txn_write_count")) != before:
+ if int(sim_eval("__import__('glob').PSRAM.psram_write_count")) != before:
break
time.sleep(.1)
else:
### unix/variant/sim_psram.py
@@ -7,6 +7,8 @@
class SimulatedPSRAMWrapper(psram.PSRAMWrapper):
def __init__(self):
+ self.psram_write_count = 0
+
# note: need heapsize=X with big number to get object so big on the heap
self._wr = bytearray(self.length)
Why this scored 58/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.