What changed, and why it matters
This commit fixes a security policy bug in the COLDCARD hardware wallet. In 'hobbled mode'—a restricted operating mode meant to limit what secrets can leave or be combined—the device was incorrectly allowing users to include the wallet's master seed as one of the parts when restoring a seed via XOR. The patch removes that option when hobbled, so a locked-down COLDCARD can no longer be tricked or misused into mixing its master seed into an externally reconstructed secret.
Treat this as a security fix and include it in the next firmware release. Review other seed-combining or temporary-seed flows for similar missing hobbled_mode checks. Verify that the new assertion cannot be triggered through menu fuzzing or automated input.
Security signals we found
hobbled-mode bypass / policy enforcement gap
seed material exposure through XOR combination
UI flow change that removes a secret-export option under a restricted mode
regression test added for the restricted-mode behavior
Evidence from the diff
The change is in shared/xor_seed.py. Previously, xor_restore_start() offered to include the current Coldcard seed words in the XOR set whenever the current seed’s word count matched the desired XOR length. It did not check pa.hobbled_mode. The patch adds a hobbled_mode guard: the ‘(1) include this Coldcard’s seed words’ option is suppressed and an assertion prevents the code path from being taken when hobbled. A regression test in testing/test_hobble.py verifies that ‘Restore Seed XOR’ remains reachable in hobbled mode, but the prompt to include the master seed no longer appears.
Changed components
shared/xor_seed.pyCOLDCARD Seed XOR restore featureCOLDCARD hobbled mode policy enforcementtesting/test_hobble.py regression coverageInspect captured patch +44 / −14
diff --git a/shared/xor_seed.py b/shared/xor_seed.py
index 4f1d949..a89bde1 100644
--- a/shared/xor_seed.py
+++ b/shared/xor_seed.py
@@ -120,13 +120,15 @@ Quiz Passed!\n
You have confirmed the details of the new split.''')
# list of seed phrases
-# stores encoded secret bytes (not word lists)
+# - stores encoded secret bytes (not word lists)
import_xor_parts = []
async def xor_all_done(data):
# So we have another part, might be done or not.
global import_xor_parts
+
chk_words = None
+
if data is None:
# special case, needs something already in import_xor_parts
target_words = len_to_numwords(len(import_xor_parts[0]))
@@ -144,7 +146,7 @@ async def xor_all_done(data):
if num_parts >= 2:
chk_words = bip39.b2a_words(seed).split(' ')
chk_word = chk_words[-1]
- msg += "If you stop now, the %dth word of the XOR-combined seed phrase\nwill be:\n\n" % target_words
+ msg += "If you stop now, the %dth word of the XOR-combined seed phrase will be:\n\n" % target_words
msg += "%d: %s\n\n" % (target_words, chk_word)
if all((not x) for x in seed):
@@ -236,12 +238,12 @@ async def xor_restore_start(*a):
# shown on import menu when no seed of any kind yet
# - or operational system
ch = await ux_show_story('''\
-To import a seed split using XOR, you must import all the parts.
-It does not matter the order (A/B/C or C/A/B) and the Coldcard
-cannot determine when you have all the parts. You may stop at
-any time and you will have a valid wallet. Combined seed parts
-have to be equal length. No way to combine seed parts of different
-length. Press %s for 24 words XOR, press (1) for 12 words XOR,
+To import a seed split using XOR, you must import all the parts. \
+It does not matter the order (A/B/C or C/A/B) and the Coldcard \
+cannot determine when you have all the parts. You may stop at \
+any time and you will have a valid wallet. Combined seed parts \
+have to be equal length.\n
+Press %s for 24 words XOR, press (1) for 12 words XOR, \
or press (2) for 18 words XOR.''' % OK, escape="12")
if ch == 'x': return
@@ -251,8 +253,6 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
elif ch == "2":
desired_num_words = 18
- curr_num_words = settings.get('words', desired_num_words)
-
global import_xor_parts
import_xor_parts.clear()
@@ -264,15 +264,20 @@ or press (2) for 18 words XOR.''' % OK, escape="12")
msg = ("Since you have a seed already on this Coldcard, the reconstructed XOR seed will be "
"temporary and not saved. Wipe the seed first if you want to commit the new value "
"into the secure element.")
- if curr_num_words == desired_num_words:
+
+ curr_num_words = settings.get('words', desired_num_words)
+ if (curr_num_words == desired_num_words) and not pa.hobbled_mode:
escape += "1"
- msg += ("\nPress (1) to include this Coldcard's seed words into the XOR seed set, "
+ msg += ("\n\nPress (1) to include this Coldcard's seed words into the XOR seed set, "
"or %s to continue without." % OK)
ch = await ux_show_story(msg, escape=escape)
- if ch == 'x': return
+ if ch == 'x':
+ return
+
if ch == '1':
+ assert not pa.hobbled_mode
dis.fullscreen("Wait...")
with SensitiveValues(enforce_delta=True) as sv:
if sv.mode == 'words':
diff --git a/testing/test_hobble.py b/testing/test_hobble.py
index 9d8e5fa..9e83f82 100644
--- a/testing/test_hobble.py
+++ b/testing/test_hobble.py
@@ -273,7 +273,8 @@ def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, i
m = cap_menu()
assert 'Generate Words' not in m
- assert all(i.startswith("Import ") or i.endswith(' Backup') for i in m), m
+ assert all((i.startswith("Import ") or i.endswith(' Backup') or i == 'Restore Seed XOR')
+ for i in m), m
words, expect_xfp = WORDLISTS[12]
@@ -451,5 +452,29 @@ def test_h_qrscan(en_okeys, set_hobble, scan_a_qr, need_keypress, press_cancel,
else:
scr = cap_screen() # stays in scanning mode
assert 'KT Blocked' in scr
+
+def test_h_seedxor(set_hobble, need_keypress, press_cancel, cap_screen, only_q1,
+ cap_story, press_select, pick_menu_item, settings_set):
+ # can start import via seed XOR, but cannot include master seed phrase
+ # as part of it.
+
+ settings_set('seedvault', True)
+ settings_set('seeds', [])
+ set_hobble(True, {'okeys'})
+
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item('Temporary Seed')
+ pick_menu_item('Restore Seed XOR')
+
+ title, story = cap_story()
+ assert 'A/B/C' in story
+ press_select() # select 24 words
+
+ title, story = cap_story()
+ assert 'Since you have' in story
+ assert "include this Coldcard's seed" not in story # WEAK: fragile if UX changes
+
+ press_cancel()
+
# EOF
Why this scored 44/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.