What changed, and why it matters
This change adds a confirmation screen when loading a backup file onto a COLDCARD hardware wallet. Before the wallet actually restores the seed from the backup, it now shows the backup's master fingerprint and asks the user to confirm or cancel. This helps prevent users from accidentally or maliciously loading the wrong backup. The change also refactors how the raw secret is extracted so the confirmation screen can compute the fingerprint before the restore happens. Clone operations skip this extra prompt because the user already sees what is being loaded on the source device.
No immediate action required; this is a defensive UX hardening change. Users should ensure they review the displayed master fingerprint before confirming backup loads. Developers should verify that all restore entry points (SD card, USB, teleport) now correctly surface the confirmation prompt and that automated tests cover both accept and refuse paths.
Security signals we found
Adds user confirmation before destructive seed restore operation
Displays master fingerprint derived from the actual raw secret being loaded, not from unverified extended public key metadata
Prevents accidental or coerced loading of an unexpected backup
Refactors secret extraction to happen before restore, enabling pre-restore verification
Clone path explicitly disables extra confirmation because source device already shows loaded seed
Evidence from the diff
The commit modifies shared/backups.py to extract the raw secret and build a BIP32 node earlier in restore_complete_doit(), then displays the master XFP (swab32(node.my_fp())) via ux_show_story() with a yes/no confirmation before calling restore_from_dict() or restore_tmp_from_dict_ll(). The extract_raw_secret() signature is changed to remove the chain argument and now returns (raw, node). restore_from_dict_ll() and restore_tmp_from_dict_ll() now accept the pre-extracted raw secret. A new ux_confirm parameter defaults to True but is set False during clone_start(). teleport.py is updated to call extract_raw_secret() before restore. Tests are updated to expect and interact with the new confirmation screen.
Changed components
shared/backups.pyshared/teleport.pytesting/test_backup.pytesting/test_ephemeral.pytesting/test_teleport.pytesting/conftest.pyInspect captured patch +164 / −43
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index eda7ca5..d039619 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -5,6 +5,7 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk4 and Q
- Enhancement: Address format guessing changed from PSBT_XPUBs derivation paths & now is based on witness/redeem script of first own PSBT input.
+- Enhancement: Show master XFP of backup secret & ask user for confirmation before loading backup.
- Enhancement: Show firmware version added to hobbled Advanced/Tools menu
- Bugfix: Exiting text input of Custom Backup Password causes yikes
diff --git a/shared/backups.py b/shared/backups.py
index 23bb3c9..eeaef4b 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -5,7 +5,7 @@
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 utils import deserialize_secret, swab32, xfp2str
from sffile import SFFile
from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text
import version, ujson
@@ -123,7 +123,7 @@ def render_backup_contents(bypass_tmp=False):
return rv.getvalue()
-def extract_raw_secret(chain, vals):
+def extract_raw_secret(vals):
# step1: the private key
# - prefer raw_secret over other values
# - TODO: fail back to other values
@@ -138,10 +138,10 @@ def extract_raw_secret(chain, vals):
# verify against xprv value (if we have it)
if 'xprv' in vals:
- check_xprv = chain.serialize_private(node)
+ check_xprv = chains.get_chain(vals.get('chain', 'BTC')).serialize_private(node)
assert check_xprv == vals['xprv'], 'xprv mismatch'
- return raw
+ return raw, node
def extract_long_secret(vals):
ls = None
@@ -154,7 +154,7 @@ def extract_long_secret(vals):
pass
return ls
-def restore_from_dict_ll(vals):
+def restore_from_dict_ll(vals, raw):
# Restore from a dict of values. Already JSON decoded.
# Need a Reboot on success, return string on failure
# - low-level version, factored out for better testing
@@ -165,12 +165,6 @@ def restore_from_dict_ll(vals):
#print("Restoring from: %r" % vals)
chain = chains.get_chain(vals.get('chain', 'BTC'))
- try:
- raw = extract_raw_secret(chain, vals)
- except Exception as e:
- return ('Unable to decode raw_secret and '
- 'restore the seed value!\n\n\n'+str(e)), None
-
dis.fullscreen("Saving...")
dis.progress_bar_show(.1)
@@ -283,15 +277,10 @@ def text_bk_parser(contents):
return vals
-async def restore_tmp_from_dict_ll(vals):
+async def restore_tmp_from_dict_ll(vals, raw):
from glob import dis
chain = chains.get_chain(vals.get('chain', 'BTC'))
- try:
- raw = extract_raw_secret(chain, vals)
- except Exception as e:
- return ('Unable to decode raw_secret and '
- 'restore the seed value!\n\n\n' + str(e))
dis.fullscreen("Applying...")
from seed import set_ephemeral_seed
@@ -308,11 +297,11 @@ async def restore_tmp_from_dict_ll(vals):
goto_top_menu()
-async def restore_from_dict(vals):
+async def restore_from_dict(vals, raw):
# Restore from a dict of values. Already JSON decoded (ie. dict object).
# Need a Reboot on success, return string on failure
- prob, need_ftux = restore_from_dict_ll(vals)
+ prob, need_ftux = restore_from_dict_ll(vals, raw)
if prob: return prob
if need_ftux:
@@ -564,7 +553,6 @@ async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
prob = await restore_complete_doit(fname_or_fd, words,
temporary=temporary)
-
if prob:
await ux_show_story(prob, title='FAILED')
@@ -627,7 +615,8 @@ def check_and_decrypt(fd, password):
'\n\nTried:\n\n' + password)
-async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False):
+async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary=False,
+ ux_confirm=True):
# 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)
@@ -682,11 +671,29 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None, temporary
except:
return "Invalid backup file."
+ try:
+ raw, node = extract_raw_secret(vals)
+ except Exception as e:
+ return ('Unable to decode raw_secret and '
+ 'restore the seed value!\n\n\n'+str(e))
+
+ if ux_confirm:
+ # check master fingerprint from raw secret that is actually being loaded
+ # master extended public keys can be wrong & is unverified
+ xfp_str = xfp2str(swab32(node.my_fp()))
+ ch = await ux_show_story("Above is the master fingerprint of the seed stored in the backup."
+ " Press %s to continue, and load backup as %s seed. Press %s"
+ " to abort." % (OK, "temporary" if temporary else "master", X),
+ title="["+xfp_str+"]")
+ if ch != "y":
+ await ux_dramatic_pause('Aborted.', 2)
+ return
+
# this leads to reboot if it works, else errors shown, etc.
if temporary:
- return await restore_tmp_from_dict_ll(vals)
+ return await restore_tmp_from_dict_ll(vals, raw)
else:
- return await restore_from_dict(vals)
+ return await restore_from_dict(vals, raw)
async def clone_start(*a):
# Begins cloning process, on target device.
@@ -769,8 +776,9 @@ back and press %s to complete clone process.''' % OK)
uos.remove(fname) # ccbk-start.json
# this will reset in successful case, no return (but delme is called)
- prob = await restore_complete_doit(incoming, words, file_cleanup=delme)
-
+ # no need to ask for UX confirmation during clone - as user can see what is loaded on source CC
+ prob = await restore_complete_doit(incoming, words, file_cleanup=delme,
+ ux_confirm=False)
if prob:
await ux_show_story(prob, title='FAILED')
diff --git a/shared/teleport.py b/shared/teleport.py
index 0594189..5ebcaba 100644
--- a/shared/teleport.py
+++ b/shared/teleport.py
@@ -348,11 +348,13 @@ async def kt_accept_values(dtype, raw):
elif dtype == 'b':
# full system backup, including master: text lines
- from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict
+ from backups import text_bk_parser, restore_tmp_from_dict_ll, restore_from_dict, extract_raw_secret
vals = text_bk_parser(raw)
assert vals # empty?
+ raw_sec, _ = extract_raw_secret(vals)
+
from flow import has_secrets
if has_secrets():
@@ -360,10 +362,10 @@ async def kt_accept_values(dtype, raw):
# need to remove key before I get into tmp seed settings
# so even if this errors out, new ktrx is needed
settings.remove_key("ktrx")
- prob = await restore_tmp_from_dict_ll(vals)
+ prob = await restore_tmp_from_dict_ll(vals, raw_sec)
else:
# we have no secret, so... reboot if it works, else errors shown, etc.
- prob = await restore_from_dict(vals)
+ prob = await restore_from_dict(vals, raw_sec)
if prob:
await ux_show_story(prob, title='FAILED')
diff --git a/testing/conftest.py b/testing/conftest.py
index 260409a..14e4308 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -2323,9 +2323,9 @@ def restore_backup_unpacked(unit_test, pick_menu_item, cap_story, cap_menu,
@pytest.fixture
def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu, press_select, word_menu_entry,
get_setting, is_q1, need_keypress, scan_a_qr, cap_screen, enter_complex,
- restore_backup_unpacked):
+ restore_backup_unpacked, press_cancel):
# restore backup with clear seed as first step
- def doit(fn, passphrase, avail_settings=None, pass_way=None, custom_bkpw=False):
+ def doit(fn, passphrase, avail_settings=None, pass_way=None, custom_bkpw=False, refuse=False):
unit_test('devtest/clear_seed.py')
m = cap_menu()
@@ -2361,8 +2361,21 @@ def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu, press_sele
scr = cap_screen()
assert fn in scr # backup fname shown at the top
assert "Enter Password for:" in scr
+
word_menu_entry(passphrase, has_checksum=False)
+ time.sleep(.2)
+ title, story = cap_story()
+ assert len(title) == 10
+ assert title[0] == "["
+ assert title[-1] == "]"
+ assert "Above is the master fingerprint of the seed stored in the backup." in story
+ assert f"load backup as master seed" in story
+ if refuse:
+ press_cancel()
+ else:
+ press_select()
+
restore_backup_unpacked(avail_settings=avail_settings)
return doit
diff --git a/testing/test_backup.py b/testing/test_backup.py
index d066d51..b1d4594 100644
--- a/testing/test_backup.py
+++ b/testing/test_backup.py
@@ -7,6 +7,7 @@ from constants import simulator_fixed_words, simulator_fixed_tprv
from charcodes import KEY_QR
from bip32 import BIP32Node
from mnemonic import Mnemonic
+from ckcc_protocol.protocol import CCProtocolPacker
@pytest.fixture
@@ -683,7 +684,6 @@ def test_restore_usb_backup(backup_system, set_seed_words, cap_story, verify_eph
# 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())
@@ -703,6 +703,13 @@ def test_restore_usb_backup(backup_system, set_seed_words, cap_story, verify_eph
time.sleep(.2)
mnemonic = mnemonic.split(" ")
+ title, story = cap_story()
+ assert f"[{xfp_str}]" == title
+ assert "Above is the master fingerprint of the seed stored in the backup." in story
+ assert f"load backup as {'temporary' if force_tmp else 'master'} seed" in story
+ press_select()
+ time.sleep(.1)
+
if force_tmp:
confirm_tmp_seed(seedvault=False)
verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=None, seed_vault=False)
@@ -717,6 +724,66 @@ def test_restore_usb_backup(backup_system, set_seed_words, cap_story, verify_eph
_, story = cap_story()
assert "now reboot" in story
+
+@pytest.mark.parametrize('way', ["sd", "usb"])
+@pytest.mark.parametrize('tmp', [True, False])
+def test_refuse_backup(way, tmp, set_seed_words, backup_system, cap_story, unit_test, microsd_path,
+ dev, press_select, word_menu_entry, X, press_cancel, cap_menu, pick_menu_item,
+ reset_seed_words, need_keypress, get_secrets, goto_home):
+
+ from test_ephemeral import SEEDVAULT_TEST_DATA
+ xfp_str, encoded_str, mnemonic = SEEDVAULT_TEST_DATA[0]
+ set_seed_words(mnemonic)
+ bk_pw = backup_system()
+
+ time.sleep(.1)
+ title, story = cap_story()
+ fname = story.split("\n\n")[1]
+ press_select()
+
+ if tmp:
+ reset_seed_words()
+ press_cancel()
+ else:
+ unit_test('devtest/clear_seed.py')
+
+ if way == "usb":
+ 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), timeout=None)
+ time.sleep(.2)
+ press_select()
+ else:
+ if tmp:
+ pick_menu_item("Advanced/Tools")
+ pick_menu_item("Temporary Seed")
+ need_keypress("4")
+ pick_menu_item("Coldcard Backup")
+ else:
+ pick_menu_item("Import Existing")
+ pick_menu_item("Restore Backup")
+
+ pick_menu_item(fname)
+
+ time.sleep(.2)
+ word_menu_entry(bk_pw, has_checksum=False)
+ time.sleep(.2)
+ title, story = cap_story()
+ assert f"[{xfp_str}]" == title
+ assert "Above is the master fingerprint of the seed stored in the backup." in story
+ assert f"load backup as {'temporary' if tmp else 'master'} seed" in story
+ assert f"Press {X} to abort" in story
+ press_cancel() # refuse backup
+ time.sleep(.1)
+ if tmp:
+ cur_mnemonic = get_secrets()["mnemonic"]
+ assert mnemonic != cur_mnemonic # nothing was loaded
+ else:
+ goto_home()
+ assert "New Seed Words" in cap_menu() # nothing was loaded
+
+
@pytest.mark.parametrize('tmp', [True, False])
def test_exit_dev_backup(tmp, unit_test, goto_home, pick_menu_item, need_keypress, src_root_dir,
microsd_path, press_cancel, cap_menu, cap_story):
diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py
index be3a6f8..ebb5b35 100644
--- a/testing/test_ephemeral.py
+++ b/testing/test_ephemeral.py
@@ -1387,6 +1387,13 @@ def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_se
word_menu_entry(bk_pw, has_checksum=False)
+ title, story = cap_story()
+ time.sleep(.5)
+ assert f"[{xfp_str}]" == title
+ assert "Above is the master fingerprint of the seed stored in the backup." in story
+ assert f"load backup as temporary seed" in story
+ press_select()
+
confirm_tmp_seed(seedvault)
time.sleep(.1)
@@ -1464,6 +1471,13 @@ def test_temporary_from_backup_usb(backup_system, set_seed_words, cap_story, ver
elif password:
enter_complex(bkpw, apply=False, b39pass=False)
+ title, story = cap_story()
+ time.sleep(.5)
+ assert f"[{xfp_str}]" == title
+ assert "Above is the master fingerprint of the seed stored in the backup." in story
+ assert f"load backup as temporary seed" in story
+ press_select()
+
time.sleep(.1)
confirm_tmp_seed(seedvault=False)
time.sleep(.1)
diff --git a/testing/test_teleport.py b/testing/test_teleport.py
index 601265a..f966f3f 100644
--- a/testing/test_teleport.py
+++ b/testing/test_teleport.py
@@ -44,7 +44,8 @@ def main_do_over(unit_test, settings_get, settings_set):
return doit
@pytest.fixture
-def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf, cap_screen_qr, readback_bbqr):
+def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_story, nfc_block4rf,
+ cap_screen_qr, readback_bbqr):
# started the process; capture pw/code and QR contents, verify NFC works
def doit(tt_code, allow_reuse=True, reset_pubkey=False):
@@ -110,7 +111,8 @@ def grab_payload(press_select, need_keypress, press_cancel, nfc_read_url, cap_s
return doit
@pytest.fixture
-def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex, cap_screen, goto_home, split_scan_bbqr):
+def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr, enter_complex,
+ cap_screen, goto_home, split_scan_bbqr):
# finish the teleport by doing QR and getting data
def doit(data, pw, expect_fail=False, expect_xfp=None):
goto_home()
@@ -145,7 +147,8 @@ def rx_complete(press_select, need_keypress, press_cancel, cap_story, scan_a_qr,
return doit
@pytest.fixture
-def tx_start(press_select, need_keypress, press_cancel, goto_home, pick_menu_item, cap_story, scan_a_qr, enter_complex, cap_screen):
+def tx_start(press_select, need_keypress, press_cancel, goto_home, pick_menu_item, cap_story,
+ scan_a_qr, enter_complex, cap_screen):
# start the Tx process, capturing password and leaving you are picker menu
def doit(rx_qr, rx_code, expect_fail=None, expect_wrong_code=False):
@@ -193,7 +196,8 @@ def test_rx_reuse(rx_start):
code3, pk3 = rx_start(allow_reuse=True, reset_pubkey=True)
assert code3 != code
-def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
+def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload,
+ rx_complete, cap_story, press_cancel, press_select):
# Send a quick-note
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
@@ -238,7 +242,8 @@ def test_tx_quick_note(rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
@pytest.mark.parametrize('testcase', [ 'weak', 'strong'])
-def test_tx_master_send(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, main_do_over):
+def test_tx_master_send(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item,
+ grab_payload, rx_complete, cap_story, press_cancel, press_select, main_do_over):
# Send master secret, but doesn't really work since same as what we have
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
@@ -281,7 +286,9 @@ def test_tx_master_send(testcase, rx_start, tx_start, cap_menu, enter_complex, p
press_cancel()
@pytest.mark.parametrize('qty', [1, 3])
-def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, need_some_passwords, need_some_notes, settings_set, settings_get):
+def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload,
+ rx_complete, cap_story, press_cancel, press_select, need_some_passwords,
+ need_some_notes, settings_set, settings_get):
# Send notes.
settings_set('notes', [])
need_some_notes()
@@ -318,7 +325,9 @@ def test_tx_notes(qty, rx_start, tx_start, cap_menu, enter_complex, pick_menu_it
@pytest.mark.parametrize('data', SEEDVAULT_TEST_DATA[0:2])
-def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_set, settings_get, goto_home, need_keypress):
+def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item,
+ grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_set,
+ settings_get, goto_home, need_keypress):
# Send seeds from vault
xfp, entropy, mnemonic = data
@@ -374,13 +383,14 @@ def test_tx_seedvault(data, rx_start, tx_start, cap_menu, enter_complex, pick_me
time.sleep(.1)
assert settings_get('xfp', -1) == simulator_fixed_xfp
-def test_rx_truncated(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, rx_complete, cap_story, press_cancel, press_select):
+def test_rx_truncated(rx_start, tx_start):
# Truncate the RX Code
code, rx_pubkey = rx_start()
- pw = tx_start(rx_pubkey[:-3], code, expect_fail='Truncated KT RX')
+ tx_start(rx_pubkey[:-3], code, expect_fail='Truncated KT RX')
-def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select):
+def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload,
+ rx_complete, cap_story, press_cancel, press_select):
# simulate wrong numeric code only -- sender doesn't know
right_code, rx_pubkey = rx_start()
@@ -690,7 +700,10 @@ def test_teleport_real_ms(dev, fake_ms_txn):
@pytest.mark.parametrize('testcase', [ 'weak', 'partial', 'strong'])
-def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, reset_seed_words, make_big_notes):
+def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick_menu_item,
+ grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get,
+ settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret,
+ reset_seed_words, make_big_notes):
# Send complete backup file.
code, rx_pubkey = rx_start()
pw = tx_start(rx_pubkey, code)
@@ -744,7 +757,10 @@ def test_send_backup(testcase, rx_start, tx_start, cap_menu, enter_complex, pick
settings_set('notes', [])
-def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, reset_seed_words, make_big_notes):
+def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload,
+ rx_complete, cap_story, press_cancel, press_select, settings_get,
+ settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret,
+ reset_seed_words, make_big_notes):
# verify: in hobbled mode, KT is blocked for everything except multisig cases
set_hobble(True)
Why this scored 46/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.