temporary seed from encrypted COLDCARD backup

This commit is contained in:
scgbckbone 2023-10-10 01:38:34 +02:00 committed by doc-hex
parent e3014390c4
commit a65b1fcc09
7 changed files with 261 additions and 121 deletions

View File

@ -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.

View File

@ -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():

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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