bugfix: invalidate dwld leases before Q1 PSRAM staging
What changed, and why it matters
This update fixes a bug where the COLDCARD Q1 wallet could accidentally let a previously authorized USB file download remain valid while it reused a special memory area (PSRAM) to prepare new data for display or signing. If a backup download lease was still active, an attacker or malicious host connected over USB might have been able to read sensitive material that was later loaded into that same memory area. The patch now clears the download lease before staging PSBT files, BBQr data, or note exports in PSRAM.
Treat this as a security fix and include it in the next firmware release. Review other PSRAM staging paths for similar lease invalidation gaps. Ensure the new regression tests pass on release builds.
Security signals we found
Clearing of an authorization token before sensitive memory reuse
New regression tests asserting that download lease is revoked after PSRAM staging operations
ChangeLog describes the fix as a security-relevant bugfix
Cross-file consistency: three distinct PSRAM staging paths patched
Evidence from the diff
The commit clears glob.ALLOWED_DOWNLOAD (a USB download lease/authorization) before operations that stage data in PSRAM: signing a PSBT file (shared/auth.py), allocating a BBQr decode buffer (shared/bbqr.py), and generating BBQr codes (shared/ux_q1.py). New tests verify that after a remote backup lease is established, scanning a BBQr, exporting notes as QR, and loading a PSBT file all revoke the lease and cause subsequent CCProtocolPacker.download() calls to fail with ‘not allowed’. The issue is a time-of-check/time-of-use style lifecycle bug where a download lease outlived the PSRAM contents it originally authorized, and later PSRAM reuse could expose unrelated data to the same authorized download.
Changed components
shared/auth.py (PSBT file signing / PSRAM staging)shared/bbqr.py (BBQr decode buffer allocation)shared/ux_q1.py (BBQr code generation)USB download lease state (glob.ALLOWED_DOWNLOAD)COLDCARD Q1 PSRAMInspect captured patch +80 / −1
### releases/Next-ChangeLog.md
@@ -85,3 +85,4 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: Reject control characters in BIP-21 payment metadata before display.
- Bugfix: Limit multisig coordinator BBQr imports before JSON parsing to prevent memory
exhaustion.
+- Bugfix: Revoke USB download access before staging PSBT and BBQr data in PSRAM.
### shared/auth.py
@@ -1102,6 +1102,9 @@ async def sign_psbt_file(filename, force_vdisk=False, slot_b=None, just_read=Fal
from glob import dis
from ux import the_ux
+ # file staging replaces any previously leased PSRAM contents
+ glob.ALLOWED_DOWNLOAD = None
+
tmp_buf = bytearray(4096)
# copy file into PSRAM
### shared/bbqr.py
@@ -367,6 +367,9 @@ def reset(self):
def alloc_buf(self, upper_bound):
# using first part of PSRAM
+ import glob
+ glob.ALLOWED_DOWNLOAD = None
+
if upper_bound >= MAX_TXN_LEN:
raise QRDecodeExplained("Too big")
### shared/ux_q1.py
@@ -1206,8 +1206,11 @@ async def show_bbqr_codes(type_code, data, msg, already_hex=False):
from bbqr import TYPE_LABELS, int2base36, b32encode, num_qr_needed
from glob import PSRAM, dis
from ux import ux_wait_keydown
+ import glob
import uqr
+ glob.ALLOWED_DOWNLOAD = None
+
assert type_code in TYPE_LABELS
dis.fullscreen('Generating BBQr...', .1)
### testing/conftest.py
@@ -698,6 +698,32 @@ def press_select(dev, has_qwerty):
f = functools.partial(_press_select, dev, has_qwerty)
return f
+
+@pytest.fixture
+def remote_backup_lease(dev, settings_set, press_select):
+ def doit():
+ settings_set('bkpw',
+ 'charge bottom tired when romance blind treat afford bus salute degree anchor')
+
+ assert dev.send_recv(CCProtocolPacker.start_backup()) is None
+ press_select()
+
+ done = None
+ for _ in range(100):
+ time.sleep(.05)
+ done = dev.send_recv(CCProtocolPacker.get_backup_file(), timeout=5000)
+ if done:
+ break
+ assert done
+
+ ll, sha = done
+ backup = dev.download_file(ll, sha, file_number=0)
+ assert backup[0:2] == b'7z'
+ return ll
+
+ return doit
+
+
@pytest.fixture
def enter_mash_entropy(pick_menu_item, press_select, need_keypress):
def doit():
### testing/test_bbqr.py
@@ -8,6 +8,7 @@
from bbqr import split_qrs, join_qrs
from charcodes import KEY_QR
from base64 import b32decode, b32encode
+from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError
# All tests in this file are exclusively meant for Q
#
@@ -242,6 +243,7 @@ def test_show_bbqr_contents(src, cap_screen_qr, sim_exec, render_bbqr, load_shar
assert data2 == data
assert ft == 'B'
+
@pytest.mark.bitcoind
@pytest.mark.reexport
@pytest.mark.parametrize('size', [ 2, 10 ] )
@@ -432,6 +434,24 @@ def test_bbqr_storage_guards_unit(sim_exec):
assert resp == "'U' 34 %r" % (b'A'*15 + b'B'*15 + b'C'*4)
+def test_scan_bbqr_revokes_download_lease(remote_backup_lease, split_scan_bbqr,
+ cap_story, dev, press_cancel):
+ remote_backup_lease()
+
+ msg = b'private scanner payload ' * 25
+ split_scan_bbqr(msg, 'U', max_version=10, encoding='2')
+ time.sleep(.5)
+
+ title, story = cap_story()
+ assert title == 'Simple Text'
+ assert msg[:50].decode() in story
+
+ with pytest.raises(CCProtoError) as exc:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 0))
+ assert 'not allowed' in str(exc.value)
+ press_cancel()
+
+
def test_bbqr_psram_reset_unit(sim_exec):
# An abandoned unaligned series must not leave fragments that are flushed
# into the next, otherwise valid, series.
### testing/test_notes.py
@@ -8,6 +8,7 @@
from constants import AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, simulator_fixed_words
from bbqr import split_qrs
from ckcc.protocol import CCProtocolPacker
+from ckcc_protocol.protocol import CCProtoError
from bip32 import BIP32Node
from mnemonic import Mnemonic
@@ -571,6 +572,22 @@ def test_top_export(way, encrypted, settings_set, settings_remove, need_some_pas
assert obj['coldcard_notes'] == notes
+def test_qr_export_revokes_download_lease(remote_backup_lease, settings_set, settings_get,
+ need_some_notes, backup_notes, dev, press_select):
+ settings_set('notes', [])
+ need_some_notes('Private note', 'not for the USB host')
+ notes = settings_get('notes')
+ remote_backup_lease()
+
+ data, _, _ = backup_notes('qr')
+ assert json.loads(data)['coldcard_notes'] == notes
+
+ with pytest.raises(CCProtoError) as exc:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 0))
+ assert 'not allowed' in str(exc.value)
+ press_select()
+
+
def test_sort_by_title(goto_notes, pick_menu_item, cap_story, need_keypress, settings_get,
settings_set, build_note, cap_menu, build_password):
### testing/test_teleport.py
@@ -13,6 +13,7 @@
from test_ephemeral import SEEDVAULT_TEST_DATA
from test_backup import make_big_notes
from test_hobble import set_hobble
+from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError
# All tests in this file are exclusively meant for Q
#
@@ -656,7 +657,8 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, ca
def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_ms_txn, cap_story,
need_keypress, cap_menu, pick_menu_item, grab_payload,
rx_complete, set_master_key, goto_home, settings_get,
- settings_set, open_microsd, import_ms_wallet, press_cancel):
+ settings_set, open_microsd, import_ms_wallet, press_cancel,
+ remote_backup_lease, dev):
clear_ms()
M, N = 2, 4
keys = import_ms_wallet(M, N, name='ms-tp', unique=11, accept=True,
@@ -666,6 +668,7 @@ def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_
fname = 'ms-tp.psbt'
open_microsd(fname, 'wb').write(psbt)
+ remote_backup_lease()
goto_home()
pick_menu_item('Advanced/Tools')
pick_menu_item('File Management')
@@ -677,6 +680,9 @@ def test_teleport_file_psbt_uses_loaded_file(make_myself_wallet, clear_ms, fake_
pass
m = cap_menu()
+ with pytest.raises(CCProtoError) as exc:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 0))
+ assert 'not allowed' in str(exc.value)
assert len(m) == N
target = next(i for i in m if 'YOU' not in i)
target_xfp = str2xfp(target[1:9])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.