diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 81b0c5f5..e423b6b6 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -1,5 +1,6 @@ ## 5.2.1 - 2023-11-XX +- New Feature: Temporary Seed from COLDCARD encrypted backup - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current active temporary seed is not saved yet, `Add current tmp` menu item is present in Seed Vault menu. diff --git a/shared/actions.py b/shared/actions.py index 416cdc71..4503b616 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -1381,6 +1381,16 @@ You must clear the wallet seed before restoring a backup because it replaces \ the seed value and the old seed would be lost.\n\n\ Visit the advanced menu and choose 'Destroy Seed'.''' +async def restore_temporary(*A): + + fn = await file_picker('Select file containing the backup ' + 'to be restored as temporary seed.', + suffix=".7z", max_size=10000) + + if fn: + import backups + await backups.restore_complete(fn, temporary=True) + async def restore_everything(*A): if not pa.is_secret_blank(): diff --git a/shared/backups.py b/shared/backups.py index ebd06e82..6fd033e8 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -119,38 +119,27 @@ def render_backup_contents(bypass_tmp=False): return rv.getvalue() -def restore_from_dict_ll(vals): - # 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 - from glob import dis - - #print("Restoring from: %r" % vals) - +def extract_raw_secret(chain, vals): # step1: the private key # - prefer raw_secret over other values # - TODO: fail back to other values - try: - chain = chains.get_chain(vals.get('chain', 'BTC')) + assert 'raw_secret' in vals + rs = vals.pop('raw_secret') - assert 'raw_secret' in vals - rs = vals.pop('raw_secret') + raw = pad_raw_secret(rs) - raw = pad_raw_secret(rs) + # check we can decode this right (might be different firmare) + opmode, bits, node = stash.SecretStash.decode(raw) + assert node - # check we can decode this right (might be different firmare) - opmode, bits, node = stash.SecretStash.decode(raw) - assert node + # verify against xprv value (if we have it) + if 'xprv' in vals: + check_xprv = chain.serialize_private(node) + assert check_xprv == vals['xprv'], 'xprv mismatch' - # verify against xprv value (if we have it) - if 'xprv' in vals: - check_xprv = chain.serialize_private(node) - assert check_xprv == vals['xprv'], 'xprv mismatch' - - except Exception as e: - return ('Unable to decode raw_secret and ' - 'restore the seed value!\n\n\n'+str(e)) + return raw +def extract_long_secret(vals): ls = None if ('long_secret' in vals) and version.has_608: try: @@ -158,6 +147,22 @@ def restore_from_dict_ll(vals): except Exception as exc: sys.print_exception(exc) # but keep going. + return ls + +def restore_from_dict_ll(vals): + # 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 + from glob import dis + + #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)) dis.fullscreen("Saving...") dis.progress_bar_show(.25) @@ -169,9 +174,8 @@ def restore_from_dict_ll(vals): # force the right chain pa.new_main_secret(raw, chain) # updates xfp/xpub - # NOTE: don't fail after this point... they can muddle thru w/ just right seed - + ls = extract_long_secret(vals) if ls is not None: try: pa.ls_change(ls) @@ -218,6 +222,30 @@ def restore_from_dict_ll(vals): import hsm hsm.restore_backup(vals['hsm_policy']) +async def restore_tmp_from_dict_ll(vals): + 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 + from actions import goto_top_menu + + await set_ephemeral_seed(raw, chain, meta="Coldcard Backup") + for k, v in vals.items(): + if not k[:8] == "setting.": + continue + key = k[8:] + if key in ["multisig"]: + # whitelist + settings.set(k, v) + + goto_top_menu() async def restore_from_dict(vals): # Restore from a dict of values. Already JSON decoded (ie. dict object). @@ -455,14 +483,15 @@ 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): +async def restore_complete(fname_or_fd, temporary=False): from ux import the_ux async def done(words): # remove all pw-picking from menu stack seed.WordNestMenu.pop_all() - prob = await restore_complete_doit(fname_or_fd, words) + prob = await restore_complete_doit(fname_or_fd, words, + temporary=temporary) if prob: await ux_show_story(prob, title='FAILED') @@ -472,7 +501,7 @@ async def restore_complete(fname_or_fd): the_ux.push(m) -async def restore_complete_doit(fname_or_fd, words, file_cleanup=None): +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) @@ -543,7 +572,10 @@ async def restore_complete_doit(fname_or_fd, words, file_cleanup=None): # but keep going! # this leads to reboot if it works, else errors shown, etc. - return await restore_from_dict(vals) + if temporary: + return await restore_tmp_from_dict_ll(vals) + else: + return await restore_from_dict(vals) async def clone_start(*a): # Begins cloning process, on target device. diff --git a/shared/seed.py b/shared/seed.py index 638ffedc..24a999ec 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -1015,7 +1015,7 @@ class EphemeralSeedMenu(MenuSystem): @classmethod def construct(cls): from glob import NFC - from actions import nfc_recv_ephemeral, import_tapsigner_backup_file, import_xprv + from actions import nfc_recv_ephemeral, import_tapsigner_backup_file, import_xprv, restore_temporary import_ephemeral_menu = [ MenuItem("12 Words", f=cls.ephemeral_seed_import, arg=12), @@ -1035,6 +1035,7 @@ class EphemeralSeedMenu(MenuSystem): MenuItem("Import Words", menu=import_ephemeral_menu), MenuItem("Import XPRV", f=import_xprv, arg=True), # ephemeral=True MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True + MenuItem("Coldcard Backup", f=restore_temporary), ] return rv diff --git a/testing/conftest.py b/testing/conftest.py index 66597853..cb7379ce 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1481,7 +1481,7 @@ def nfc_block4rf(sim_eval): for i in range(timeout*4): rv = sim_eval('glob.NFC.rf_on') if rv: break - sleep(0.250) + time.sleep(.25) else: raise pytest.fail("NFC timeout") @@ -1743,13 +1743,14 @@ def check_and_decrypt_backup(microsd_path): os.remove(xfn_path) # does decryption; at least for CRC purposes - args = ['7z', 'e', '-p' + ' '.join(passphrase), pn, xfname, '-o' + '../unix/work/MicroSD',] + args = ['7z', 'e', '-p' + ' '.join(passphrase), pn, xfname, '-o' + microsd_path("")] out = check_output(args, encoding='utf8') assert "Extracting archive" in out, out assert "Everything is Ok" in out, out with open(xfn_path, "r") as f: res = f.read() + return res return doit @@ -1792,13 +1793,14 @@ def restore_backup_cs(unit_test, pick_menu_item, cap_story, cap_menu, # useful fixtures -from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn -from test_multisig import make_ms_address, clear_ms, make_myself_wallet +from test_backup import backup_system from test_bip39pw import set_bip39_pw from test_drv_entro import derive_bip85_secret, activate_bip85_ephemeral from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto_eph_seed_menu from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable +from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn +from test_multisig import make_ms_address, clear_ms, make_myself_wallet from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed from test_seed_xor import restore_seed_xor from test_ux import enter_complex, pass_word_quiz, word_menu_entry diff --git a/testing/test_backup.py b/testing/test_backup.py index d087597d..b7179efd 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -4,6 +4,115 @@ from pycoin.key.BIP32Node import BIP32Node from mnemonic import Mnemonic +def decode_backup(txt): + import json + vals = dict() + trimmed = dict() + for ln in txt.split('\n'): + if not ln: continue + if ln[0] == '#': continue + + k, v = ln.split(' = ', 1) + + v = json.loads(v) + + if k.startswith('duress_') or k.startswith('fw_'): + # no space in USB xfer for thesE! + trimmed[k] = v + else: + vals[k] = v + + return vals, trimmed + + +@pytest.fixture +def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, + cap_story, need_keypress, cap_screen_qr, pass_word_quiz, + get_setting): + def doit(reuse_pw=False, save_pw=False, st=None, ct=False): + # st -> seed type + # ct -> cleartext backup + if reuse_pw: + settings_set('bkpw', ' '.join('zoo' for _ in range(12))) + else: + settings_remove('bkpw') + + goto_home() + pick_menu_item('Advanced/Tools') + pick_menu_item('Backup') + pick_menu_item('Backup System') + + title, body = cap_story() + if st: + if st == "b39pass": + assert "BIP39 passphrase is in effect" in body + assert "ignores passphrases and produces backup of main seed" in body + assert "(2) to back-up BIP39 passphrase wallet" in body + if st == "eph": + assert "An temporary seed is in effect" in body + assert "so backup will be of that seed" in body + + need_keypress("y") + time.sleep(.1) + title, body = cap_story() + + if ct: + # cleartext backup + if ' 1: zoo' in body: + need_keypress("x") + + need_keypress("6") + time.sleep(.1) + _, story = cap_story() + assert "Are you SURE ?!?" in story + assert "**NOT** be encrypted" in story + need_keypress("y") + return # nothing more to be done + + if reuse_pw: + assert ' 1: zoo' in body + assert '12: zoo' in body + need_keypress('y') + words = ['zoo'] * 12 + + time.sleep(0.1) + title, body = cap_story() + else: + assert title == 'NO-TITLE' + assert 'Record this' in body + assert 'password:' in body + + words = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':'] + assert len(words) == 12 + + print("Passphrase: %s" % ' '.join(words)) + + if 'QR Code' in body: + need_keypress('1') + got_qr = cap_screen_qr().decode('ascii').lower().split() + assert [w[0:4] for w in words] == got_qr + need_keypress('y') + + # pass the quiz! + count, title, body = pass_word_quiz(words) + assert count >= 4 + assert "same words next time" in body + assert "Press (1) to save" in body + if save_pw: + need_keypress('1') + time.sleep(.1) + + assert get_setting('bkpw') == ' '.join(words) + else: + need_keypress('x') + time.sleep(.01) + assert get_setting('bkpw', 'xxx') == 'xxx' + + return words + + return doit + + @pytest.mark.qrcode @pytest.mark.parametrize('multisig', [False, 'multisig']) @pytest.mark.parametrize('st', ["b39pass", "eph", None]) @@ -16,7 +125,7 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre cap_screen_qr, reuse_pw, save_pw, settings_set, settings_remove, generate_ephemeral_words, set_bip39_pw, verify_backup_file, check_and_decrypt_backup, restore_backup_cs, clear_ms, seedvault, - restore_main_seed, import_ephemeral_xprv): + restore_main_seed, import_ephemeral_xprv, backup_system): # Make an encrypted 7z backup, verify it, and even restore it! clear_ms() reset_seed_words() @@ -48,73 +157,11 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre node = import_ephemeral_xprv("sd", from_main=True, seed_vault=seedvault) restore_main_seed(seed_vault=seedvault, preserve_settings=True) - if reuse_pw: - settings_set('bkpw', ' '.join('zoo' for _ in range(12))) - else: - settings_remove('bkpw') - - goto_home() - pick_menu_item('Advanced/Tools') - pick_menu_item('Backup') - pick_menu_item('Backup System') + words = backup_system(reuse_pw=reuse_pw, save_pw=save_pw, st=st) + time.sleep(.1) title, body = cap_story() - if st: - if st == "b39pass": - assert "BIP39 passphrase is in effect" in body - assert "ignores passphrases and produces backup of main seed" in body - assert "(2) to back-up BIP39 passphrase wallet" in body - if st == "eph": - assert "An temporary seed is in effect" in body - assert "so backup will be of that seed" in body - need_keypress("y") - time.sleep(.1) - title, body = cap_story() - - if reuse_pw: - assert ' 1: zoo' in body - assert '12: zoo' in body - need_keypress('y') - words = ['zoo']*12 - - time.sleep(0.1) - title, body = cap_story() - else: - assert title == 'NO-TITLE' - assert 'Record this' in body - assert 'password:' in body - - words = [w[3:].strip() for w in body.split('\n') if w and w[2] == ':'] - assert len(words) == 12 - - print("Passphrase: %s" % ' '.join(words)) - - if 'QR Code' in body: - need_keypress('1') - got_qr = cap_screen_qr().decode('ascii').lower().split() - assert [w[0:4] for w in words] == got_qr - need_keypress('y') - - # pass the quiz! - count, title, body = pass_word_quiz(words) - assert count >= 4 - assert "same words next time" in body - assert "Press (1) to save" in body - if save_pw: - need_keypress('1') - time.sleep(.1) - - assert get_setting('bkpw') == ' '.join(words) - else: - need_keypress('x') - time.sleep(.01) - assert get_setting('bkpw', 'xxx') == 'xxx' - - time.sleep(0.1) - title, body = cap_story() - - time.sleep(0.1) if st == "b39pass" and multisig: # correct settings switch back? # multisig is only in main wallet @@ -356,26 +403,6 @@ def test_trick_backups(goto_trick_menu, clear_all_tricks, repl, unit_test, assert 'Coldcard backup file' in bk - def decode_backup(txt): - import json - vals = dict() - trimmed = dict() - for ln in txt.split('\n'): - if not ln: continue - if ln[0] == '#': continue - - k, v = ln.split(' = ', 1) - - v = json.loads(v) - - if k.startswith('duress_') or k.startswith('fw_'): - # no space in USB xfer for thesE! - trimmed[k] = v - else: - vals[k] = v - - return vals, trimmed - # decode it vals, trimmed = decode_backup(bk) diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index cdd6fd28..db648c96 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -9,7 +9,7 @@ from ckcc.protocol import CCProtocolPacker from txn import fake_txn from test_ux import word_menu_entry from pycoin.key.BIP32Node import BIP32Node -from helpers import xfp2str +from helpers import xfp2str, a2b_hex WORDLISTS = { @@ -1207,4 +1207,71 @@ def test_add_current_active(reset_seed_words, settings_set, import_ephemeral_xpr need_keypress("y") verify_ephemeral_secret_ui(xpub=node.hwif(), seed_vault=True) + +@pytest.mark.parametrize('multisig', [False, 'multisig']) +@pytest.mark.parametrize('seedvault', [False, True]) +@pytest.mark.parametrize('data', SEEDVAULT_TEST_DATA) +def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_setting, + data, need_keypress, cap_story, set_encoded_secret, + reset_seed_words, check_and_decrypt_backup, + goto_eph_seed_menu, pick_menu_item, word_menu_entry, + verify_ephemeral_secret_ui, seedvault, settings_set, + seed_vault_enable, confirm_tmp_seed, settings_path, + seed_vault_delete, restore_main_seed): + xfp_str, encoded_str, mnemonic = data + encoded = a2b_hex(encoded_str) + if mnemonic: + vlen = len(encoded) + assert vlen in [16, 24, 32] + marker = 0x80 | ((vlen // 8) - 2) + encoded = bytes([marker]) + encoded + + set_encoded_secret(encoded) + + settings_set("chain", "XTN") + + if multisig: + import_ms_wallet(15, 15, dev_key=True) + need_keypress('y') + time.sleep(.1) + assert len(get_setting('multisig')) == 1 + + # ACTUAL BACKUP + bk_pw = backup_system() + time.sleep(.1) + title, story = cap_story() + fname = story.split("\n\n")[1] + + check_and_decrypt_backup(fname, bk_pw) + + # restore fixed simulator + reset_seed_words() + seed_vault_enable(seedvault) + + goto_eph_seed_menu() + pick_menu_item("Coldcard Backup") + + time.sleep(.1) + _, story = cap_story() + if "Select file containing the backup" in story: + need_keypress("y") + time.sleep(.1) + pick_menu_item(fname) + + word_menu_entry(bk_pw) + + confirm_tmp_seed(seedvault) + + time.sleep(.1) + if mnemonic: + mnemonic = mnemonic.split(" ") + + xfp = verify_ephemeral_secret_ui(mnemonic=mnemonic, xpub=None, # xpub veriphy ephemeral secret not tested here + seed_vault=seedvault) + + if seedvault: + seed_vault_delete(xfp, not False) + else: + restore_main_seed(False) + # EOF