mk4 fix word entry after restore via USB
What changed, and why it matters
This commit fixes a user-interface bug on the COLDCARD MK4 that occurred when restoring a wallet backup over USB. After a USB restore, the device was supposed to let the user enter the backup password as a series of seed words, but the on-screen word-entry menu did not work correctly because the device was not coming from the normal menu flow. The fix detects the USB case and uses a simpler, direct word-entry path instead. There is no direct evidence this is a security vulnerability; it appears to be a usability/functional bug fix.
Treat as a normal bug fix. No urgent security action is indicated. If reviewing for release notes, describe it as a usability fix for MK4 backup restore over USB. No CVE or advisory is warranted based on the supplied materials.
Security signals we found
UI/UX flow bug in backup restore path
No cryptographic or secret-handling changes
No privilege escalation, memory safety, or authentication bypass signals
Test-only change from enter_text to enter_complex suggests test harness alignment, not a security fix
Evidence from the diff
The change modifies restore_complete() in shared/backups.py to accept a new usb flag. When restoring via USB (RemoteRestoreBackup in shared/auth.py), the flag is set to True. In that case, instead of pushing a WordNestMenu onto the UX stack and relying on menu interaction callbacks, the code calls seed.WordNestMenu.get_n_words(12) directly and then invokes the done() callback. shared/seed.py is refactored to expose get_n_words() and get_word() classmethods that block until words are collected. The test files are updated to use enter_complex() instead of enter_text() for password entry in USB restore tests. The patch is functional and UI-focused; no cryptographic, authentication, or secret-handling logic is changed.
Changed components
shared/auth.pyshared/backups.pyshared/seed.pytesting/test_backup.pytesting/test_ephemeral.pyInspect captured patch +32 / −19
diff --git a/shared/auth.py b/shared/auth.py
index 412c539..e1df2c4 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -1183,7 +1183,7 @@ class RemoteRestoreBackup(UserAuthorizedAction):
tmp, noun = self.to_tmp()
if await ux_confirm("Restore uploaded backup as a %s seed?" % noun):
from backups import restore_complete
- await restore_complete(self.file_len, tmp, self.to_words())
+ await restore_complete(self.file_len, tmp, self.to_words(), usb=True)
else:
self.refused = True
diff --git a/shared/backups.py b/shared/backups.py
index bf36a63..34d8fae 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -554,7 +554,7 @@ async def verify_backup_file(fname):
await ux_show_story("Backup file CRC checks out okay.\n\nPlease note this is only a check against accidental truncation and similar. Targeted modifications can still pass this test.")
-async def restore_complete(fname_or_fd, temporary=False, words=True):
+async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
from ux import the_ux
async def done(words):
@@ -573,10 +573,15 @@ async def restore_complete(fname_or_fd, temporary=False, words=True):
from ux_q1 import seed_word_entry
return await seed_word_entry('Enter Password:', num_pw_words,
done_cb=done, has_checksum=False)
- # give them a menu to pick from, and start picking
- m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
- the_ux.push(m)
+ # give them a menu to pick from, and start picking
+ if usb:
+ # we're not originating from a menu
+ words = await seed.WordNestMenu.get_n_words(12)
+ await done(words)
+ else:
+ m = seed.WordNestMenu(num_words=num_pw_words, has_checksum=False, done_cb=done)
+ the_ux.push(m)
else:
pwd = [] # cleartext if words=None
diff --git a/shared/seed.py b/shared/seed.py
index 4b76031..d1667e2 100644
--- a/shared/seed.py
+++ b/shared/seed.py
@@ -154,7 +154,7 @@ class WordNestMenu(MenuSystem):
done_cb = None
def __init__(self, num_words=None, has_checksum=True, done_cb=commit_new_words,
- items=None, is_commit=False, menu_cbf=None, prefix=""):
+ items=None, is_commit=False, menu_cbf=None, prefix="", words=None):
if num_words is not None:
WordNestMenu.target_words = num_words
@@ -163,6 +163,9 @@ class WordNestMenu(MenuSystem):
WordNestMenu.done_cb = done_cb
is_commit = True
+ if words:
+ WordNestMenu.words = words
+
if not items:
ch = letter_choices(prefix)
if menu_cbf:
@@ -175,7 +178,15 @@ class WordNestMenu(MenuSystem):
super(WordNestMenu, self).__init__(items)
@classmethod
- async def get_n_words(cls, nwords):
+ async def get_n_words(cls, num_words):
+ rv = []
+ for _ in range(num_words):
+ rv = await cls.get_word(rv, num_words)
+
+ return rv
+
+ @classmethod
+ async def get_word(cls, words=None, target_words=None):
# Just block until N words are provided. May only work before menus start?
from glob import numpad
@@ -184,17 +195,15 @@ class WordNestMenu(MenuSystem):
if c.label[-1] == '-':
lc = c.label[0:-1]
else:
- lc = ""
cls.words.append(c.label)
- if len(cls.words) >= nwords:
- numpad.abort_ux()
- return
+ numpad.abort_ux()
+ return
m = cls(prefix=lc, menu_cbf=menu_done_cbf)
the_ux.push(m)
- await m.interact()
+ await the_ux.interact()
- m = cls(num_words=nwords, menu_cbf=menu_done_cbf, has_checksum=False)
+ m = cls(num_words=target_words, menu_cbf=menu_done_cbf, has_checksum=False, words=words)
the_ux.push(m)
await the_ux.interact()
diff --git a/testing/test_backup.py b/testing/test_backup.py
index e427f15..9e2e15a 100644
--- a/testing/test_backup.py
+++ b/testing/test_backup.py
@@ -647,8 +647,8 @@ def test_bkpw_override(reset_seed_words, override_bkpw, goto_home, pick_menu_ite
@pytest.mark.parametrize('force_tmp', [True, False])
def test_restore_usb_backup(backup_system, set_seed_words, cap_story, verify_ephemeral_secret_ui,
settings_slots, reset_seed_words, word_menu_entry, confirm_tmp_seed,
- dev, microsd_path, press_select, btype, enter_text, force_tmp,
- unit_test, restore_main_seed, cap_menu):
+ dev, microsd_path, press_select, btype, force_tmp,
+ unit_test, restore_main_seed, cap_menu, is_q1, enter_complex):
from test_ephemeral import SEEDVAULT_TEST_DATA
xfp_str, encoded_str, mnemonic = SEEDVAULT_TEST_DATA[2]
@@ -698,7 +698,7 @@ def test_restore_usb_backup(backup_system, set_seed_words, cap_story, verify_eph
if btype == "classic":
word_menu_entry(bk_pw, has_checksum=False)
elif password:
- enter_text(bkpw)
+ enter_complex(bkpw, apply=False, b39pass=False)
time.sleep(.2)
mnemonic = mnemonic.split(" ")
diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py
index 6057f9d..49eb6de 100644
--- a/testing/test_ephemeral.py
+++ b/testing/test_ephemeral.py
@@ -1412,8 +1412,7 @@ def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_se
@pytest.mark.parametrize('btype', ["classic", "custom_bkpw", "plaintext"])
def test_temporary_from_backup_usb(backup_system, set_seed_words, cap_story, verify_ephemeral_secret_ui,
settings_slots, reset_seed_words, word_menu_entry, confirm_tmp_seed,
- dev, microsd_path, press_select, btype,
- enter_text):
+ dev, microsd_path, press_select, btype, enter_complex):
xfp_str, encoded_str, mnemonic = SEEDVAULT_TEST_DATA[0]
set_seed_words(mnemonic)
@@ -1461,7 +1460,7 @@ def test_temporary_from_backup_usb(backup_system, set_seed_words, cap_story, ver
if btype == "classic":
word_menu_entry(bk_pw, has_checksum=False)
elif password:
- enter_text(bkpw)
+ enter_complex(bkpw, apply=False, b39pass=False)
time.sleep(.1)
confirm_tmp_seed(seedvault=False)
Why this scored 21/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.