What changed, and why it matters
This commit adds a new USB command that lets a connected computer restore a COLDCARD wallet backup onto the device. The user must confirm on the device screen, and the backup file's checksum is verified. However, because restoring a backup replaces the wallet seed, the feature is security-sensitive. The commit also blocks this command in HSM (unattended signing) mode, which is a good protective measure. There is no direct evidence of a vulnerability in the patch, but the feature increases the attack surface available over USB.
Review the new 'rest' command handler for authorization edge cases, ensure the checksum verification cannot be bypassed, confirm that user confirmation cannot be spoofed or automated by a compromised host, and verify that HSM-mode blocking is enforced in production firmware and not only in tests. Consider fuzzing the bitflag and file_len parameters.
Security signals we found
New USB attack surface: seed/backup restore command exposed over USB
On-device user confirmation required before restore proceeds
Uploaded backup checksum verified against self.file_checksum before restore action is initiated
Command explicitly blocked in HSM mode
Backup decryption and parsing reuse existing restore_complete path
No explicit rate limiting or anti-hammering logic visible for repeated 'rest' attempts
Evidence from the diff
The change introduces a ‘rest’ USB command (RemoteRestoreBackup) that restores a backup already uploaded into PSRAM via the existing file-upload mechanism. It parses a bitflag for custom password, plaintext, and force-temporary options, requires on-device user confirmation via ux_confirm, verifies the uploaded file’s SHA256 checksum against self.file_checksum, and routes to restore_complete. The restore path is refactored to accept an integer length (PSRAM-backed SFFile) in addition to SD-card filenames. The command is added to the HSM-mode blocklist in test_hsm.py, preventing unattended restore while HSM policy is active.
Changed components
shared/usb.pyshared/auth.pyshared/backups.pyshared/hsm.py (indirectly via import cleanup)testing/test_backup.pytesting/test_ephemeral.pytesting/test_hsm.pyInspect captured patch +276 / −58
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 7ad1626..86af1d4 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -22,6 +22,7 @@ Spending policies for "Single Signers" adds new spending policy options:
- Enhancement: Show QR codes of output addresses in transaction output explorer. Explorer is
now offered for transactions of all sizes, not just complex ones.
- Enhancement: Added file rename, when listing contents of SD card.
+- Enhancement: Added ability to restore Coldcard backup via USB (TODO version of updated ckcc)
- Bugfix: If all change outputs have `nValue=0`, they were not shown in UX.
- Bugfix: Disallow negative input/output amounts in PSBT.
- Bugfix: Fix filesystem initialization after Wife LFS or Destroy Seed.
diff --git a/shared/auth.py b/shared/auth.py
index 28d91b1..c074ca2 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -11,7 +11,7 @@ from uhashlib import sha256
from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, SUPPORTED_ADDR_FORMATS
from public_constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED
from sffile import SFFile
-from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys
+from ux import ux_show_story, abort_and_goto, ux_dramatic_pause, ux_clear_keys, ux_confirm
from ux import show_qr_code, OK, X, abort_and_push, AbortInteraction
from usb import CCBusyError
from utils import HexWriter, xfp2str, problem_file_line, cleanup_deriv_path, B2A, show_single_address
@@ -1136,6 +1136,51 @@ class RemoteBackup(UserAuthorizedAction):
self.done()
+class RemoteRestoreBackup(UserAuthorizedAction):
+ def __init__(self, file_len, bitflag):
+ super().__init__()
+ self.file_len = file_len
+ self.custom_pwd = bitflag & 1
+ self.plaintext = bitflag & 2
+ self.force_tmp = bitflag & 4
+
+ def to_words(self):
+ # conversion to "words" argument of "restore_complete" function
+ if self.plaintext:
+ return None
+ elif self.custom_pwd:
+ return False
+ return True
+
+ def to_tmp(self):
+ # conversion to "temporary" argument of "restore_complete" function
+ from pincodes import pa
+ if pa.is_secret_blank() and not self.force_tmp:
+ # no master secret & not forcing tmp
+ # will load backup as master seed
+ return False, "master"
+
+ # has master secret --> load backup as tmp
+ # secret is blank but user forcing tmp
+ return True, "temporary"
+
+ async def interact(self):
+ try:
+ # requires confirm from user
+ 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())
+ else:
+ self.refused = True
+
+ except BaseException as exc:
+ self.failed = "Error during backup restore."
+ # sys.print_exception(exc)
+ finally:
+ self.done()
+
+
def start_remote_backup():
# tell the local user the secret words, and then save to SPI flash
# USB caller has to come back and download encrypted contents.
@@ -1146,6 +1191,12 @@ def start_remote_backup():
# kill any menu stack, and put our thing at the top
abort_and_goto(UserAuthorizedAction.active_request)
+def start_remote_restore_backup(file_len, bitflag):
+ UserAuthorizedAction.cleanup()
+ UserAuthorizedAction.active_request = RemoteRestoreBackup(file_len, bitflag)
+ # kill any menu stack, and put our thing at the top
+ abort_and_goto(UserAuthorizedAction.active_request)
+
class NewPassphrase(UserAuthorizedAction):
def __init__(self, pw):
diff --git a/shared/backups.py b/shared/backups.py
index faf0232..bf36a63 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -6,9 +6,10 @@ import compat7z, stash, ckcc, chains, gc, sys, bip39, uos, ngu
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
from utils import deserialize_secret
+from sffile import SFFile
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text
import version, ujson
-from uio import StringIO
+from uio import StringIO, BytesIO
import seed
from glob import settings
from pincodes import pa
@@ -457,8 +458,6 @@ async def write_complete_backup(pwd, fname_pattern, write_sflash=False,
if write_sflash:
# for use over USB and unit testing: commit file into PSRAM
- from sffile import SFFile
-
with SFFile(0, max_size=MAX_BACKUP_FILE_SIZE, message='Saving...') as fd:
if zz:
fd.write(hdr)
@@ -588,62 +587,86 @@ async def restore_complete(fname_or_fd, temporary=False, words=True):
await done(pwd)
+
+def check_and_decrypt(fd, password):
+ try:
+ compat7z.check_file_headers(fd)
+ except Exception as e:
+ raise RuntimeError('Unable to read backup file.'
+ ' Has it been touched?\n\nError: '+str(e))
+
+ from glob import dis
+ dis.fullscreen("Decrypting...")
+ try:
+ zz = compat7z.Builder()
+ fname, contents = zz.read_file(fd, password, MAX_BACKUP_FILE_SIZE,
+ progress_fcn=dis.progress_bar_show)
+
+ # simple quick sanity checks
+ assert fname.endswith('.txt') # was == 'ckcc-backup.txt'
+ assert contents[0:1] == b'#' and contents[-1:] == b'\n'
+ return contents
+
+ except Exception as e:
+ # assume everything here is "password wrong" errors
+ raise RuntimeError('Unable to decrypt backup file. Incorrect password?'
+ '\n\nTried:\n\n' + password)
+
+
async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False):
# Open file, read it, maybe decrypt it; return string if any error
# - some errors will be shown, None return in that case
# - no return if successful (due to reboot)
- from glob import dis
from files import CardSlot, CardMissingError, needs_microsd
# build password
password = ' '.join(words)
prob = None
- try:
- with CardSlot(readonly=True) as card:
- # filename already picked, taste it and maybe consider using its data.
- try:
- fd = open(fname_or_fd, 'rb') if isinstance(fname_or_fd, str) else fname_or_fd
- except:
- return 'Unable to open backup file.\n\n' + str(fname_or_fd)
+ if isinstance(fname_or_fd, int):
+ # USB restore - backup is already in PSRAM, fname of fd is length
+ # TXN_INPUT_OFFSET = 0
+ with SFFile(0, length=fname_or_fd) as fd:
+ if not words:
+ contents = fd.read(fname_or_fd)
+ else:
+ # read full size, then decrypt
+ fd = BytesIO(fd.read(fname_or_fd))
+ try:
+ contents = check_and_decrypt(fd, password)
+ except RuntimeError as e:
+ return str(e)
+ else:
+ try:
+ with CardSlot(readonly=True) as card:
+ # filename already picked, taste it and maybe consider using its data.
+ try:
+ fd = open(fname_or_fd, 'rb')
+ except:
+ return 'Unable to open backup file.\n\n' + str(fname_or_fd)
+
+ try:
+ if words:
+ contents = check_and_decrypt(fd, password)
+ else:
+ contents = fd.read()
- try:
- if not words:
- contents = fd.read()
- else:
- try:
- compat7z.check_file_headers(fd)
- except Exception as e:
- return 'Unable to read backup file. Has it been touched?\n\nError: ' \
- + str(e)
-
- dis.fullscreen("Decrypting...")
- try:
- zz = compat7z.Builder()
- fname, contents = zz.read_file(fd, password, MAX_BACKUP_FILE_SIZE,
- progress_fcn=dis.progress_bar_show)
-
- # simple quick sanity checks
- assert fname.endswith('.txt') # was == 'ckcc-backup.txt'
- assert contents[0:1] == b'#' and contents[-1:] == b'\n'
-
- except Exception as e:
- # assume everything here is "password wrong" errors
- #print("pw wrong? %s" % e)
-
- return ('Unable to decrypt backup file. Incorrect password?'
- '\n\nTried:\n\n' + password)
- finally:
- fd.close()
+ except RuntimeError as e:
+ return str(e)
+ finally:
+ fd.close()
if file_cleanup:
file_cleanup(fname_or_fd)
- except CardMissingError:
- await needs_microsd()
- return
+ except CardMissingError:
+ await needs_microsd()
+ return
- vals = text_bk_parser(contents)
+ try:
+ vals = text_bk_parser(contents)
+ except:
+ return "Invalid backup file."
# this leads to reboot if it works, else errors shown, etc.
if temporary:
diff --git a/shared/hsm.py b/shared/hsm.py
index 937975f..c619798 100644
--- a/shared/hsm.py
+++ b/shared/hsm.py
@@ -4,8 +4,7 @@
#
# Unattended signing of transactions and messages, subject to a set of rules.
#
-import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version
-from sffile import SFFile
+import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
from utils import cleanup_payment_address
from pincodes import AE_LONG_SECRET_LEN
diff --git a/shared/usb.py b/shared/usb.py
index e3f3d05..44f5c5a 100644
--- a/shared/usb.py
+++ b/shared/usb.py
@@ -564,7 +564,6 @@ class USBHandler:
# STILL waiting on user
return None
-
if cmd == 'pwok':
# return new root xpub
xpub = req.result
@@ -599,6 +598,15 @@ class USBHandler:
from auth import start_remote_backup
return start_remote_backup()
+ if cmd == 'rest':
+ # restore backup from what is already uploaded in PSRAM
+ file_len, file_sha, bf = unpack_from('<I32sB', args)
+ if file_sha != self.file_checksum.digest():
+ return b'err_Checksum'
+
+ from auth import start_remote_restore_backup
+ return start_remote_restore_backup(file_len, bf)
+
if cmd == 'blkc':
# report which blockchain we are configured for
from chains import current_chain
diff --git a/testing/test_backup.py b/testing/test_backup.py
index a81af59..e427f15 100644
--- a/testing/test_backup.py
+++ b/testing/test_backup.py
@@ -93,10 +93,7 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item,
# st -> seed type
# ct -> cleartext backup
if reuse_pw:
- if isinstance(reuse_pw, list):
- assert len(reuse_pw) == 12
- else:
- assert reuse_pw is True # default
+ if reuse_pw is True:
reuse_pw = ['zoo' for _ in range(12)]
settings_set('bkpw', ' '.join(reuse_pw))
@@ -129,15 +126,19 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item,
need_keypress("6")
time.sleep(.1)
- _, story = cap_story()
- assert "Are you SURE ?!?" in story
+ title, story = cap_story()
+ assert "Are you SURE ?!?" in (title if is_q1 else story)
assert "**NOT** be encrypted" in story
press_select()
return # nothing more to be done
if reuse_pw:
- assert (' 1: %s' % reuse_pw[0]) in body
- assert ('12: %s' % reuse_pw[-1]) in body
+ if len(reuse_pw) == 1:
+ reuse_pw = reuse_pw[0]
+ assert f"{reuse_pw[0]}...{reuse_pw[-1]}" in body
+ else:
+ assert (' 1: %s' % reuse_pw[0]) in body
+ assert ('12: %s' % reuse_pw[-1]) in body
press_select()
words = ['zoo'] * 12
else:
@@ -641,4 +642,79 @@ def test_bkpw_override(reset_seed_words, override_bkpw, goto_home, pick_menu_ite
for pw, fn in zip(test_cases, fnames):
restore_backup_cs(fn, pw, custom_bkpw=True)
+
+@pytest.mark.parametrize('btype', ["classic", "custom_bkpw", "plaintext"])
+@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):
+
+ from test_ephemeral import SEEDVAULT_TEST_DATA
+ xfp_str, encoded_str, mnemonic = SEEDVAULT_TEST_DATA[2]
+ set_seed_words(mnemonic)
+ bkpw = 34*"Z"
+ plaintext = (btype == "plaintext")
+ password = False
+
+ # ACTUAL BACKUP
+ if plaintext:
+ bk_pw = backup_system(ct=True)
+ elif btype == "custom_bkpw":
+ # encrypted but with custom pwd
+ password = True
+ bk_pw = backup_system(reuse_pw=[bkpw])
+ else:
+ # classic word-based encrypted backup
+ bk_pw = backup_system()
+
+ time.sleep(.1)
+ title, story = cap_story()
+ fname = story.split("\n\n")[1]
+
+ # remove all saved slots, one of them will be the one where we just created backup
+ # slot where backup was created needs to be removed - otherwise we will load back to it
+ # and see multisig wallet there without the need for backup to actually copy it
+ for s in settings_slots():
+ try:
+ os.remove(s)
+ except: pass
+
+ # clear seed
+ unit_test('devtest/clear_seed.py')
+
+ from ckcc_protocol.protocol import CCProtocolPacker
+ with open(microsd_path(fname), "rb") as f:
+ file_len, sha = dev.upload_file(f.read())
+
+ dev.send_recv(CCProtocolPacker.restore_backup(file_len, sha, password, plaintext, force_tmp),
+ timeout=None)
+ time.sleep(.2)
+ _, story = cap_story()
+ assert f"Restore uploaded backup as a {'temporary' if force_tmp else 'master'} seed" in story
+ press_select()
+
+ time.sleep(.1)
+ if btype == "classic":
+ word_menu_entry(bk_pw, has_checksum=False)
+ elif password:
+ enter_text(bkpw)
+
+ time.sleep(.2)
+ mnemonic = mnemonic.split(" ")
+
+ if force_tmp:
+ confirm_tmp_seed(seedvault=False)
+ verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=None, seed_vault=False)
+ restore_main_seed()
+ time.sleep(.1)
+ assert "New Seed Words" in cap_menu()
+ else:
+ _, story = cap_story()
+ assert "configured for best security practices" in story
+ press_select()
+ time.sleep(.1)
+ _, story = cap_story()
+ assert "now reboot" in story
+
# EOF
diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py
index 332a283..6057f9d 100644
--- a/testing/test_ephemeral.py
+++ b/testing/test_ephemeral.py
@@ -1409,6 +1409,66 @@ def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_se
else:
restore_main_seed(False)
+@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):
+
+ xfp_str, encoded_str, mnemonic = SEEDVAULT_TEST_DATA[0]
+ set_seed_words(mnemonic)
+ bkpw = 32*"X"
+ plaintext = (btype == "plaintext")
+ password = False
+
+ # ACTUAL BACKUP
+ if plaintext:
+ bk_pw = backup_system(ct=True)
+ elif btype == "custom_bkpw":
+ # encrypted but with custom pwd
+ password = True
+ bk_pw = backup_system(reuse_pw=[bkpw])
+ else:
+ # classic word-based encrypted backup
+ bk_pw = backup_system()
+
+ time.sleep(.1)
+ title, story = cap_story()
+ fname = story.split("\n\n")[1]
+
+ # remove all saved slots, one of them will be the one where we just created backup
+ # slot where backup was created needs to be removed - otherwise we will load back to it
+ # and see multisig wallet there without the need for backup to actually copy it
+ for s in settings_slots():
+ try:
+ os.remove(s)
+ except: pass
+
+ # restore fixed simulator
+ reset_seed_words()
+
+ from ckcc_protocol.protocol import CCProtocolPacker
+ with open(microsd_path(fname), "rb") as f:
+ file_len, sha = dev.upload_file(f.read())
+
+ dev.send_recv(CCProtocolPacker.restore_backup(file_len, sha, password, plaintext), timeout=None)
+ time.sleep(.2)
+ _, story = cap_story()
+ assert "Restore uploaded backup as a temporary seed" in story
+ press_select()
+
+ time.sleep(.1)
+ if btype == "classic":
+ word_menu_entry(bk_pw, has_checksum=False)
+ elif password:
+ enter_text(bkpw)
+
+ time.sleep(.1)
+ confirm_tmp_seed(seedvault=False)
+ time.sleep(.1)
+ mnemonic = mnemonic.split(" ")
+ verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=None, seed_vault=False)
+
def test_tmp_upgrade_disabled(reset_seed_words, pick_menu_item, cap_story,
cap_menu, goto_home, unit_test,
diff --git a/testing/test_hsm.py b/testing/test_hsm.py
index 35c5bff..b5e4d68 100644
--- a/testing/test_hsm.py
+++ b/testing/test_hsm.py
@@ -1078,7 +1078,7 @@ def test_storage_locker(package, count, start_hsm, dev):
def test_usb_cmds_block(quick_start_hsm, dev):
# check these commands return errors (test whitelist)
block_list = [
- 'rebo', 'dfu_', 'enrl', 'enok',
+ 'rebo', 'dfu_', 'enrl', 'enok', 'rest',
'back', 'pass', 'bagi', 'hsms', 'nwur', 'rmur', 'pwok', 'bkok',
]
@@ -1086,8 +1086,8 @@ def test_usb_cmds_block(quick_start_hsm, dev):
for cmd in block_list:
with pytest.raises(CCProtoError) as ee:
- got = dev.send_recv(cmd)
- assert 'HSM' in str(ee)
+ dev.send_recv(cmd)
+ assert 'Not allowed in HSM mode' in str(ee)
def test_unit_local_conf(sim_exec, enter_local_code, quick_start_hsm):
# just testing our fixture really
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.