What changed, and why it matters
This commit adjusts how COLDCARD's 'hobbled mode' handles temporary seeds and the Seed Vault. Hobbled mode is a restricted operating mode (often tied to a special PIN or duress-like setup). The change makes temporary seeds read-only: users can import and use an existing temporary seed for signing, but cannot generate new words or save the temporary seed into the Seed Vault. It also exposes a 'Temporary Seed' menu under Advanced/Tools when a spending-policy flag called 'okeys' is enabled. The commit is titled 'testing' and appears to be a work-in-progress feature patch with added tests, not a published security fix.
Treat this as a feature/refactoring commit in progress rather than a confirmed vulnerability. Reviewers should verify that hobbled mode cannot be bypassed to write to Seed Vault, that the removed assertion in make_ephemeral_seed_menu does not open an unintended code path, and that the 'okeys' policy correctly enforces read-only access. If this commit is part of a security release, request the vendor's changelog or advisory for confirmation.
Security signals we found
hobbled mode restriction change
Seed Vault write suppression in hobbled mode
temporary seed import allowed but generation blocked in hobbled mode
menu gating by sssp_spending_policy('okeys')
assertion removal around hobbled ephemeral seed menu
test expansion for hobbled temporary seed workflows
Evidence from the diff
The diff modifies shared/actions.py, shared/ccc.py, shared/flow.py, shared/seed.py, and two test files. Key behavior changes: (1) make_top_menu() now selects HobbledTopMenu only when the PIN login is successful and has secrets, rather than before the success assertion. (2) HobbledAdvancedMenu gains a ‘Temporary Seed’ entry gated by sssp_spending_policy(‘okeys’). (3) add_seed_to_vault() returns immediately in hobbled mode, preventing writes to Seed Vault. (4) set_ephemeral_seed() skips the vault capture when hobbled. (5) SeedVaultMenu hides ‘add current tmp’ and ‘Temporary Seed’ creation options when hobbled. (6) EphemeralSeedMenu hides ‘Generate Words’ when hobbled and removes an assertion that ephemeral seed menu cannot be opened in hobbled mode. (7) ccc.py updates the help text for the ‘Related Keys’ (okeys) policy to clarify read-only Seed Vault access. Tests in test_hobble.py are expanded to cover importing temporary seeds via words, QR, Tapsigner, Coldcard backup, and XPRV, verifying that Seed Vault storage is not offered.
Changed components
shared/actions.pyshared/ccc.pyshared/flow.pyshared/seed.pytesting/test_ephemeral.pytesting/test_hobble.pyInspect captured patch +122 / −24
diff --git a/shared/actions.py b/shared/actions.py
index a644df1..893f11a 100644
--- a/shared/actions.py
+++ b/shared/actions.py
@@ -991,14 +991,13 @@ def make_top_menu():
elif pa.is_blank():
# let them play a little before picking a PIN first time
m = MenuSystem(VirginSystem, should_cont=lambda: pa.is_blank())
- elif pa.hobbled_mode:
- # let them do a few things, but not all the things.
- m = MenuSystem(HobbledTopMenu)
else:
assert pa.is_successful(), "nonblank but wrong pin"
if pa.has_secrets():
- _cls = NormalSystem[:]
+ # let them do a few things, but not all the things, when "hobbled"
+ _cls = HobbledTopMenu[:] if pa.hobbled_mode else NormalSystem[:]
+
if pa.tmp_value or settings.get("hmx", False):
active_xfp = settings.get("xfp", 0)
sl, sr = ("[", "]") if pa.tmp_value else ("<", ">")
diff --git a/shared/ccc.py b/shared/ccc.py
index 2222565..ed7f3fa 100644
--- a/shared/ccc.py
+++ b/shared/ccc.py
@@ -1194,7 +1194,7 @@ class SSSPConfigMenu(MenuSystem):
menu=lambda *a: SpendingPolicyMenu.be_a_submenu(SSSPFeature.get_policy())),
SSSPCheckedMenuItem('Word Check', 'words', 'To change Spending Policy, in addition to special PIN, you must provide the first and last seed words.'),
SSSPCheckedMenuItem('Allow Notes', 'notes', 'Allow (read-only) access to secure notes and passwords? Otherwise, they are inaccessible.'),
- SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, or Seed Vault (if any). Same spending Policy applies to all.'),
+ SSSPCheckedMenuItem('Related Keys', 'okeys', 'Allow access to BIP-39 passphrase wallets based on master seed, and Seed Vault (read-only). Single Spending Policy applies to all.'),
#MenuItem('Test Word Challenge', f=sssp_word_challenge), # XXX test only?
]
diff --git a/shared/flow.py b/shared/flow.py
index 3ddf6c4..17b0e17 100644
--- a/shared/flow.py
+++ b/shared/flow.py
@@ -519,6 +519,8 @@ HobbledAdvancedMenu = [
MenuItem('Export Wallet', menu=WalletExportMenu, shortcut='x'), # also inside FileMgmt
MenuItem('Teleport Multisig PSBT', predicate=qr_and_ms, f=kt_send_file_psbt),
MenuItem("View Identity", f=view_ident),
+ MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu,
+ predicate=lambda: sssp_spending_policy('okeys')),
MenuItem('Paper Wallets', f=make_paper_wallet),
MenuItem('NFC Tools', predicate=nfc_enabled, menu=HobbledNFCToolsMenu, shortcut=KEY_NFC),
MenuItem("Destroy Seed", f=clear_seed),
diff --git a/shared/seed.py b/shared/seed.py
index eeb1054..418f4d0 100644
--- a/shared/seed.py
+++ b/shared/seed.py
@@ -493,6 +493,10 @@ async def add_seed_to_vault(encoded, origin=None, label=None):
if in_seed_vault(encoded):
return
+ # stay "read only" in hobbled mode
+ if pa.hobbled_mode:
+ return
+
main_xfp = settings.master_get("xfp", 0)
# parse encoded
@@ -531,7 +535,7 @@ async def add_seed_to_vault(encoded, origin=None, label=None):
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
is_restore=False, origin=None, label=None):
# Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp.
- if not is_restore:
+ if not is_restore and not pa.hobbled_mode:
await add_seed_to_vault(encoded, origin=origin, label=label)
dis.fullscreen("Wait...")
@@ -1034,11 +1038,11 @@ class SeedVaultMenu(MenuSystem):
seeds = list(seed_vault_iter())
if not seeds:
- assert not pa.hobbled_mode
rv.append(MenuItem('(none saved yet)'))
- if pa.tmp_value:
- rv.append(add_current_tmp)
- rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
+ if not pa.hobbled_mode:
+ if pa.tmp_value:
+ rv.append(add_current_tmp)
+ rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
else:
wipe_if_deltamode()
@@ -1162,7 +1166,7 @@ class EphemeralSeedMenu(MenuSystem):
]
rv = [
- MenuItem("Generate Words", menu=gen_ephemeral_menu),
+ MenuItem("Generate Words", menu=gen_ephemeral_menu, predicate=not pa.hobbled_mode),
MenuItem('Import from QR Scan', predicate=version.has_qr,
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
MenuItem("Import Words", menu=import_ephemeral_menu),
@@ -1175,7 +1179,6 @@ class EphemeralSeedMenu(MenuSystem):
async def make_ephemeral_seed_menu(*a):
- assert not pa.hobbled_mode
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
# force a warning on them, unless they are already doing it.
diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py
index db9764b..87a1f38 100644
--- a/testing/test_ephemeral.py
+++ b/testing/test_ephemeral.py
@@ -221,10 +221,14 @@ def restore_main_seed(goto_home, pick_menu_item, cap_story, cap_menu,
@pytest.fixture
def confirm_tmp_seed(need_keypress, cap_story, press_select):
- def doit(seedvault=False, expect_xfp=None):
+ def doit(seedvault=False, expect_xfp=None, check_sv_not_offered=False):
time.sleep(0.3)
title, story = cap_story()
+
+ if check_sv_not_offered:
+ assert "to store temporary seed into Seed Vault" not in story
+
if "Press (1) to store temporary seed into Seed Vault" in story:
if seedvault:
need_keypress("1") # store it
diff --git a/testing/test_hobble.py b/testing/test_hobble.py
index 79f6eef..f55d812 100644
--- a/testing/test_hobble.py
+++ b/testing/test_hobble.py
@@ -11,15 +11,12 @@ from bbqr import join_qrs
from charcodes import KEY_QR, KEY_NFC
from base64 import b32encode
from constants import *
-from test_ephemeral import SEEDVAULT_TEST_DATA
-from test_notes import need_some_notes
+from test_ephemeral import SEEDVAULT_TEST_DATA, WORDLISTS
+from test_ephemeral import confirm_tmp_seed, verify_ephemeral_secret_ui
+from test_ux import word_menu_entry
-'''TODO
-
-When hobbled...
-
-- temp seeds are read-only: no create, no rename, etc.
-- seed vault can be accessed tho
+'''
+TODO -- When hobbled...
- login sequence
1) system has lgto value: should get bypass pin, main pin, delay, then main pin again
@@ -128,6 +125,9 @@ def test_menu_contents(set_hobble, pick_menu_item, cap_menu, en_okeys, en_notes,
if en_nfc:
adv_expect.add('NFC Tools')
+ if en_okeys:
+ adv_expect.add('Temporary Seed')
+
m = cap_menu()
assert set(m) == adv_expect, "Adv menu wrong"
@@ -209,7 +209,7 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
else:
settings_set('seeds', [])
xfp, enc = SEEDVAULT_TEST_DATA[0][0:2]
- settings_set("seeds", [(xfp, '80'+enc, f"Menu Label", "meta")])
+ settings_set("seeds", [(xfp, '80'+enc, f"Menu Label", "meta-source")])
set_hobble(True, {'okeys'})
@@ -228,7 +228,7 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
pick_menu_item(m[0])
title, story = cap_story()
- assert 'Origin:\nmeta' in story
+ assert 'Origin:\nmeta-source' in story
press_cancel()
pick_menu_item('Use This Seed')
@@ -263,7 +263,97 @@ def test_h_seedvault(sv_empty, set_hobble, pick_menu_item, cap_menu, settings_se
m = cap_menu()
assert 'Seed Vault' not in m
-# BIP-39 passphrases
+@pytest.mark.parametrize('mode', [ 'words', 'qr', 'xprv', 'tapsigner', 'coldcard' ])
+def test_h_tempseeds(mode, set_hobble, pick_menu_item, cap_menu, settings_set, is_q1, sim_exec, settings_remove, restore_main_seed, settings_get, press_cancel, press_select, cap_story, word_menu_entry, confirm_tmp_seed, verify_ephemeral_secret_ui, scan_a_qr, tapsigner_encrypted_backup, need_keypress, enter_hex, open_microsd):
+ '''
+ - can import and use a key for signing
+ - NOT offered chance to save into seedvault
+ '''
+ if not is_q1 and mode == 'qr': return
+
+ settings_set('seedvault', True)
+ settings_set('seeds', [])
+
+ set_hobble(True, {'okeys'})
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item('Temporary Seed')
+
+ m = cap_menu()
+ assert 'Generate Words' not in m
+ assert all(i.startswith("Import ") or i.endswith(' Backup') for i in m), m
+
+ words, expect_xfp = WORDLISTS[12]
+
+ if mode == 'words':
+ # just quick tests here, not in-depth
+ # - from test_ephemeral_seed_import_words()
+ pick_menu_item("Import Words")
+ pick_menu_item(f"12 Words")
+ time.sleep(0.1)
+ word_menu_entry(words.split())
+ elif mode == 'qr':
+ pick_menu_item("Import from QR Scan")
+ val = ' '.join(words.split()).upper()
+ scan_a_qr(val)
+ time.sleep(0.1)
+ elif mode == 'tapsigner':
+ # like test_ephemeral_seed_import_tapsigner()
+ fname, backup_key_hex, node = tapsigner_encrypted_backup('sd', testnet=True)
+ expect_xfp = node.fingerprint().hex().upper()
+ pick_menu_item("Tapsigner Backup")
+ time.sleep(0.1)
+ need_keypress('1')
+ time.sleep(0.1)
+ pick_menu_item(fname)
+
+ time.sleep(0.1)
+ _, story = cap_story()
+ assert "your TAPSIGNER" in story
+
+ press_select() # yes I have backup key
+ enter_hex(backup_key_hex)
+
+ elif mode == 'coldcard':
+ # like test_temporary_from_backup()
+ # - but skip making new bk file
+ fn = 'data/tip-index-famous-embark-tobacco-rice-attitude-interest-mask-random-amazing-initial.7z'
+ pw = fn[5:-3].split('-')
+
+ contents = open(fn, 'rb').read()
+ with open_microsd('example.7z', 'wb') as fd:
+ fd.write(contents)
+
+ pick_menu_item("Coldcard Backup")
+ time.sleep(0.1)
+ need_keypress('1')
+ time.sleep(0.1)
+ pick_menu_item('example.7z')
+
+ word_menu_entry(pw, has_checksum=False)
+
+ title, story = cap_story()
+ assert title == 'FAILED'
+ assert 'successfully tested recovery' in story
+
+ press_select()
+
+ return
+ elif mode == 'xprv':
+ # meh todo ... XPRV case from file
+ pick_menu_item("Import XPRV")
+ press_cancel()
+ return
+ else:
+ raise pytest.fail(f'{mode} not done')
+
+ confirm_tmp_seed(seedvault=False, check_sv_not_offered=True)
+
+ verify_ephemeral_secret_ui(expected_xfp=expect_xfp, mnemonic=None, seed_vault=False)
+
+ pick_menu_item("Restore Master")
+ press_select()
+# TODO: BIP-39 passphrases and temp seeds
+# TODO: test usb commands are blocked
# EOF
Why this scored 35/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.