Restrict signing and Teleport retry QR scans to expected types
What changed, and why it matters
This update fixes a bug in the COLDCARD Q1 hardware wallet where scanning a QR code from the 'Ready To Sign' or 'Key Teleport retry' screens could accidentally replace the device's master seed with seed words or an extended private key shown in the QR code. The fix restricts those screens so they only accept the specific QR types they expect (a PSBT file for signing, or a Key Teleport code), and rejects seed/private-key QR codes. A related assertion that protects the master seed was also tightened.
Treat this as a security fix and include it in the next firmware release. Users with Q1 devices should update when available. Review other QR entry points for similar missing expect_type constraints, and ensure set_seed_value() callers cannot be reached with an active secret.
Security signals we found
Unintended master seed replacement via QR scan in signing/Teleport flows
Missing input-type validation on context-sensitive QR scanner
Tightened secure-element secret-overwrite guard in set_seed_value()
Regression test demonstrates secret remains unchanged after rejected scans
Evidence from the diff
The patch hardens QR scanning in shared/ux_q1.py by adding an expect_type parameter to scan_anything() and rejecting decoded QR results whose type does not match. shared/actions.py now calls _scan_any_qr(expect_type=’psbt’) from ready2sign(), and shared/teleport.py calls scan_anything(expect_type=’teleport’) from the Key Teleport receive retry path. shared/seed.py changes set_seed_value() from asserting ‘not pa.tmp_value’ to asserting ‘not pa.has_secrets()’, broadening the guard so it refuses to overwrite any active secret. A regression test in testing/test_ux.py confirms that scanning seed words or an xprv from the PSBT or Key Teleport screens is rejected and does not alter the current secret.
Changed components
shared/ux_q1.py (QRScannerInteraction.scan_anything)shared/actions.py (ready2sign / _scan_any_qr)shared/teleport.py (kt_start_rx)shared/seed.py (set_seed_value)COLDCARD Q1 firmwareInspect captured patch +70 / −8
### releases/Next-ChangeLog.md
@@ -34,4 +34,7 @@ your addition and anything else already in this file.**
## 1.5.3Q - 2026-09-xx
+- Bugfix: Prevent unintended master seed replacement when scanning seed words
+ or an extended private key from Ready To Sign or the Key Teleport retry screen.
+
- tbd
### shared/actions.py
@@ -2033,7 +2033,7 @@ async def ready2sign(*a):
if NFC and picked == KEY_NFC:
await NFC.start_psbt_rx()
if picked == KEY_QR:
- await _scan_any_qr()
+ await _scan_any_qr(expect_type='psbt')
return
@@ -2449,11 +2449,11 @@ async def scan_any_qr(menu, label, item):
expect_secret, tmp = item.arg
await _scan_any_qr(expect_secret, tmp)
-async def _scan_any_qr(expect_secret=False, tmp=False):
+async def _scan_any_qr(expect_secret=False, tmp=False, expect_type=None):
from ux_q1 import QRScannerInteraction
x = QRScannerInteraction()
try:
- await x.scan_anything(expect_secret=expect_secret, tmp=tmp)
+ await x.scan_anything(expect_secret=expect_secret, tmp=tmp, expect_type=expect_type)
except Exception as e:
await ux_show_story(msg="Failed to import from QR.\n\n%s\n%s" % (e, problem_file_line(e)),
title="ERROR")
### shared/seed.py
@@ -979,7 +979,7 @@ def xprv_to_encoded_secret(xprv):
def set_seed_value(words=None, encoded=None, chain=None):
# Save the seed words (or other encoded private key) into secure element.
# BIP-39 passphrase is not set at this point (empty string).
- assert not pa.tmp_value, "temporary seed active"
+ assert not pa.has_secrets()
if words:
nv = seed_words_to_encoded_secret(words)
### shared/teleport.py
@@ -65,7 +65,7 @@ async def kt_start_rx(*a):
if ch == KEY_QR:
# help them scan now!
x = QRScannerInteraction()
- await x.scan_anything(expect_secret=False, tmp=False)
+ await x.scan_anything(expect_type='teleport')
return
elif ch == 'r':
# wipe and restart; sender's work might be lost
### shared/ux_q1.py
@@ -923,7 +923,7 @@ def addr_taster(got):
return await self.scan_general(prompt, addr_taster, line2=line2, enter_quits=True)
- async def scan_anything(self, expect_secret=False, tmp=False):
+ async def scan_anything(self, expect_secret=False, tmp=False, expect_type=None):
# start a QR scan, and act on what we find, whatever it may be.
from ux import ux_show_story
from pincodes import pa
@@ -932,6 +932,9 @@ async def scan_anything(self, expect_secret=False, tmp=False):
while 1:
prompt = 'Scan any QR code, or CANCEL' if not expect_secret else \
'Scan XPRV or Seed Words, or CANCEL'
+ if expect_type:
+ label = {'psbt': 'PSBT', 'teleport': 'Key Teleport'}[expect_type]
+ prompt = 'Scan %s, or CANCEL' % label
try:
got = await self.scan(prompt, line2=problem)
@@ -940,6 +943,8 @@ async def scan_anything(self, expect_secret=False, tmp=False):
# Figure out what we got.
what, vals = decode_qr_result(got, expect_secret=expect_secret)
+ if expect_type and what != expect_type:
+ raise QRDecodeExplained('Expected ' + label)
break
except QRDecodeExplained as exc:
problem = str(exc)
### testing/devtest/set_seed.py
@@ -7,7 +7,7 @@
from pincodes import pa
from glob import settings
import stash
-from seed import set_seed_value, PassphraseMenu
+from seed import seed_words_to_encoded_secret, PassphraseMenu
from utils import xfp2str
from actions import goto_top_menu
from nvstore import SettingsObject
@@ -22,7 +22,12 @@
PassphraseMenu.pp_sofar = ''
SettingsObject.master_sv_data = {}
SettingsObject.master_nvram_key = None
-set_seed_value(main.WORDS)
+# Test setup deliberately replaces the current secret.
+raw = seed_words_to_encoded_secret(main.WORDS)
+pa.change(new_secret=raw)
+pa.new_main_secret(raw)
+pa.reset()
+pa.login()
stash.SensitiveValues.clear_cache()
settings.set('chain', 'XTN')
### testing/test_ux.py
@@ -1333,6 +1333,55 @@ def test_bip39_pw_signing_xfp_ux(pick_menu_item, press_select, cap_story, enter_
reset_seed_words() # for subsequent tests
+@pytest.mark.parametrize('context', ['PSBT', 'Key Teleport'])
+def test_context_qr_rejects_secrets(context, only_q1, reset_seed_words, microsd_wipe,
+ goto_home, pick_menu_item, need_keypress, scan_a_qr,
+ sim_exec, cap_screen, cap_story, fake_txn, press_cancel,
+ settings_set, settings_remove):
+ from base64 import b64encode
+
+ reset_seed_words()
+ microsd_wipe()
+ snapshot = 'from pincodes import pa; RV.write(repr((bytes(pa.fetch(bypass_tmp=True)), pa.tmp_value)))'
+ before = sim_exec(snapshot)
+ goto_home()
+ if context == 'PSBT':
+ pick_menu_item('Ready To Sign')
+ else:
+ settings_set('ktrx', '01' * 32)
+ pick_menu_item('Advanced/Tools')
+ pick_menu_item('Key Teleport (start)')
+ assert cap_story()[0] == 'Reuse Pubkey?'
+ need_keypress(KEY_QR)
+
+ words = ' '.join(['abandon'] * 11 + ['about'])
+ xprv = BIP32Node.from_master_secret(bytes(32), netcode='XTN').hwif(as_private=True)
+ for secret in (words, xprv):
+ scan_a_qr(secret)
+ time.sleep(1)
+ assert sim_exec(snapshot) == before
+ assert 'Expected ' + context in cap_screen()
+
+ if context == 'PSBT':
+ qr = b64encode(fake_txn(1, 1)).decode()
+ expected = 'OK TO SEND'
+ else:
+ qr = sim_exec('from teleport import generate_rx_code, short_bbqr; import ngu; '
+ 'RV.write(short_bbqr("R", generate_rx_code(ngu.secp256k1.keypair())[1]))')
+ expected = 'Teleport Password (number)'
+ scan_a_qr(qr)
+ for _ in range(30):
+ if expected in cap_screen():
+ break
+ time.sleep(.1)
+ else:
+ pytest.fail('Accepted QR did not reach ' + expected)
+ press_cancel()
+ assert sim_exec(snapshot) == before
+ if context == 'Key Teleport':
+ settings_remove('ktrx')
+
+
def test_q1_seed_word_entry_bug(word_menu_entry, unit_test, pick_menu_item,
is_q1, do_keypresses, press_select, expect_ftux):
# internal/issues/750Why this scored 78/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.