temporary seed from encrypted COLDCARD backup
This commit is contained in:
parent
e3014390c4
commit
a65b1fcc09
@ -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.
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user