diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 9ff55625..391bbda0 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -16,6 +16,7 @@ This lists the new changes that have not yet been published in a normal release. about successful master seed verification. - Enhancement: Catch more DeltaMode cases in XOR path. Thanks to [@dmonakhov](https://github.com/dmonakhov)) +- Enhancement: BKPW override (for "developers") - Change: If derivation path is omitted during message signing, default is used based on address format (`m/44h/0h/0h/0/0` for p2pkh, and `m/84h/0h/0h/0/0` for p2wpkh). Default is no longer root (m). @@ -46,4 +47,3 @@ This lists the new changes that have not yet been published in a normal release. - New Feature: Sign message from QR scan (format has to be JSON) - Enhancement: Sign scanned Simple Text by pressing (0). Next screens query information about key to use. - Bugfix: Properly re-draw status bar after Restore Master on COLDCARD without master seed. - diff --git a/shared/actions.py b/shared/actions.py index aafc3797..ef6a68d1 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -11,7 +11,7 @@ from ubinascii import hexlify as b2a_hex from utils import imported, problem_file_line, get_filesize, encode_seed_qr from utils import xfp2str, B2A, txid_from_fname from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted -from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X +from ux import ux_enter_bip32_index, ux_input_text, import_export_prompt, OK, X, ux_render_words from export import make_json_wallet, make_summary_file, make_descriptor_wallet_export from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export from export import generate_unchained_export, generate_electrum_wallet @@ -603,7 +603,6 @@ consequences.''', escape='4') def render_master_secrets(mode, raw, node): # Render list of words, or XPRV / master secret to text. import stash, chains - from ux import ux_render_words c = chains.current_chain() qr_alnum = False @@ -1422,6 +1421,59 @@ async def restore_everything_cleartext(*A): if prob: await ux_show_story(prob, title='FAILED') +async def bkpw_override(*A): + # allows user to: + # 1.) manually set bkpw + # 2.) remove existing bkpw setting + # 3.) view current active bkpw + from backups import bkpw_min_len + + if pa.is_secret_blank(): + return + + if pa.is_deltamode(): + import callgate + callgate.fast_wipe() + + while True: + pwd = settings.get("bkpw", None) + + msg = ("Password used to encrypt COLDCARD backup." + "\n\nPress (0) to change backup password") + esc = "0" + if pwd: + esc += "12" + msg += ", (1) to forget current password, (2) to show current active backup password." + + ch = await ux_show_story(title="BKPW", msg=msg, escape=esc) + if ch == "x": return + elif ch == "1": + if await ux_confirm("Delete current stored password?"): + settings.remove_key("bkpw") + settings.save() + await ux_dramatic_pause("Deleted.", 2) + + elif ch == "2": + if await ux_confirm('The next screen will show current active backup password.' + '\n\nAnyone with knowledge of the password will ' + 'be able to decrypt your backups.'): + await ux_show_story(pwd) + + elif ch == "0": + if version.has_qwerty: + from notes import get_a_password + npwd = await get_a_password(pwd, min_len=bkpw_min_len) + else: + npwd = await ux_input_text(pwd, prompt="Your Backup Password", + min_len=bkpw_min_len, max_len=128) + + if (npwd is None) or (npwd == pwd): continue + + settings.set('bkpw', npwd) + settings.save() + await ux_dramatic_pause("Saved.", 2) + + async def wipe_filesystem(*A): if not await ux_confirm('''\ Erase internal filesystem and rebuild it. Resets contents of internal flash area \ diff --git a/shared/backups.py b/shared/backups.py index f61b1709..329ceb16 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -15,6 +15,7 @@ from pincodes import pa # we make passwords with this number of words num_pw_words = const(12) +bkpw_min_len = const(32) # max size we expect for a backup data file (encrypted or cleartext) # - limited by size of LFS area of flash, since all settings are held there @@ -309,7 +310,7 @@ async def restore_from_dict(vals): async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): from stash import bip39_passphrase - words = None + pwd = None skip_quiz = False bypass_tmp = False @@ -329,28 +330,40 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): "so backup will be of that seed."): return - stored_words = settings.get('bkpw', None) + # first check if bkpw already defined on tmp seed settings + stored_pwd = None + master_pwd = settings.master_get("bkpw", None) + if pa.tmp_value: + stored_pwd = settings.get('bkpw', None) - if stored_words: - stored_words = stored_words.split() - ch = await ux_show_story("Use same backup file password as last time?\n\n" - " 1: %s\n ...\n%d: %s" - % (stored_words[0], len(stored_words), stored_words[-1]), sensitive=True) + if not stored_pwd and master_pwd: + stored_pwd = master_pwd + + if stored_pwd: + # we can have words or other type of password here + split_pwd = stored_pwd.split() + if len(split_pwd) == num_pw_words: # weak + hint = " 1: %s\n ...\n%d: %s" % (split_pwd[0], len(split_pwd), split_pwd[-1]) + else: + hint = " %s...%s" % (stored_pwd[0], stored_pwd[-1]) + + ch = await ux_show_story("Use same backup file password as last time?\n\n" + hint, + sensitive=True) if ch == 'y': - words = stored_words + pwd = stored_pwd # string, not list skip_quiz = True - if not words: + if not pwd: # Pick a password: like bip39 but no checksum word # b = bytearray(32) while 1: ckcc.rng_bytes(b) - words = bip39.b2a_words(b).split(' ')[0:num_pw_words] + pwd = bip39.b2a_words(b).rsplit(' ', num_pw_words)[0] - ch = await seed.show_words(words, - prompt="Record this (%d word) backup file password:\n", escape='6') + ch = await seed.show_words(prompt="Record this (%d word) backup file password:\n", + words=pwd.split(" "), escape='6') if ch == '6' and not write_sflash: # Secret feature: plaintext mode @@ -367,43 +380,43 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False): break - if words and not skip_quiz: + if pwd and not skip_quiz: # quiz them, but be nice and do a shorter test. - ch = await seed.word_quiz(words, limited=(num_pw_words//3)) + ch = await seed.word_quiz(pwd.split(" "), limited=(num_pw_words//3)) if ch == 'x': return - if words and words != stored_words: + if pwd and pwd != stored_pwd: ch = await ux_show_story("Would you like to use these same words next time you perform a backup?" " Press (1) to save them into this Coldcard for next time.", escape='1') if ch == '1': - settings.put('bkpw', ' '.join(words)) - settings.save() - elif stored_words: - settings.remove_key('bkpw') + settings.set('bkpw', pwd) # if on tmp save to tmp, do not update master settings.save() + # stop droping bkpw just because someone decided to use differrent password + # elif stored_words: + # settings.remove_key('bkpw') + # settings.save() - return await write_complete_backup(words, fname_pattern, write_sflash=write_sflash, + return await write_complete_backup(pwd, fname_pattern, write_sflash=write_sflash, bypass_tmp=bypass_tmp) -async def write_complete_backup(words, fname_pattern, write_sflash=False, +async def write_complete_backup(pwd, fname_pattern, write_sflash=False, allow_copies=True, bypass_tmp=False): # Just do the writing from glob import dis from files import CardSlot # Show progress: - dis.fullscreen('Encrypting...' if words else 'Generating...') + dis.fullscreen('Encrypting...' if pwd else 'Generating...') body = render_backup_contents(bypass_tmp=bypass_tmp).encode() gc.collect() - if words: + if pwd: # NOTE: Takes a few seconds to do the key-streching, but little actual # time to do the encryption. - pw = ' '.join(words) - zz = compat7z.Builder(password=pw, progress_fcn=dis.progress_bar_show) + zz = compat7z.Builder(password=pwd, progress_fcn=dis.progress_bar_show) zz.add_data(body) # pick random filename, but ending in .txt @@ -742,11 +755,9 @@ async def clone_write_data(*a): my_pubkey = pair.pubkey().to_bytes(False) session_key = pair.ecdh_multiply(his_pubkey) - words = [b2a_hex(session_key).decode()] - fname = b2a_hex(my_pubkey).decode() + '-ccbk.7z' - await write_complete_backup(words, fname, allow_copies=False, bypass_tmp=True) + await write_complete_backup(b2a_hex(session_key).decode(), fname, allow_copies=False, bypass_tmp=True) await ux_show_story("Done.\n\nTake this MicroSD card back to other Coldcard and continue from there.") diff --git a/shared/flow.py b/shared/flow.py index f7654fd7..930191b2 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -236,6 +236,7 @@ DevelopersMenu = [ MenuItem("Serial REPL", f=dev_enable_repl), MenuItem('Warm Reset', f=reset_self), MenuItem("Restore Txt Bkup", f=restore_everything_cleartext), + MenuItem("BKPW Override", menu=bkpw_override), ] AdvancedVirginMenu = [ # No PIN, no secrets yet (factory fresh) diff --git a/shared/notes.py b/shared/notes.py index 2cb145f2..4ae26098 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -50,7 +50,7 @@ Press ENTER to enable and get started otherwise CANCEL.''', return NotesMenu(NotesMenu.construct()) -async def get_a_password(old_value): +async def get_a_password(old_value, min_len=0, max_len=128): # Get a (new) password as a string. # - does some fun generation as well. @@ -104,9 +104,9 @@ async def get_a_password(old_value): handlers = {KEY_F1: _pick_12, KEY_F2: _pick_24, KEY_F3: _pick_dense, KEY_F4: _do_dumb, KEY_F6: _toggle_case, KEY_F5: _bip85} - return await ux_input_text(old_value, confirm_exit=False, max_len=128, scan_ok=True, - b39_complete=True, prompt='Password', placeholder='(optional)', - funct_keys=(fmsg, handlers)) + return await ux_input_text(old_value, confirm_exit=False, max_len=max_len, min_len=min_len, + scan_ok=True, b39_complete=True, prompt='Password', + placeholder='(optional)', funct_keys=(fmsg, handlers)) class NotesMenu(MenuSystem): diff --git a/shared/nvstore.py b/shared/nvstore.py index 442273fa..0331fc99 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -84,10 +84,11 @@ from utils import call_later_ms # prelogin settings - do not need to be part of other saved settings # PRELOGIN_SETTINGS = ["_skip_pin", "nick", "rngk", "lgto", "kbtn", "terms_ok"] # keep these settings only if unspecified on the other end -KEEP_IF_BLANK_SETTINGS = ["bkpw", "wa", "sighshchk", "emu", "rz", "b39skip", - "axskip", "del", "pms", "idle_to", "batt_to", "bright"] +KEEP_IF_BLANK_SETTINGS = ["wa", "sighshchk", "emu", "rz", "b39skip", + "axskip", "del", "pms", "idle_to", "batt_to", + "bright"] -SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words'] +SEEDVAULT_FIELDS = ['seeds', 'seedvault', 'xfp', 'words', "bkpw"] NUM_SLOTS = const(100) SLOTS = range(NUM_SLOTS) diff --git a/shared/ux_q1.py b/shared/ux_q1.py index a9974954..5bc88071 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -162,7 +162,6 @@ async def ux_input_text(value, confirm_exit=False, hex_only=False, max_len=100, # to make longer single-line value onto screen # - confirm_exit default False here, because so easy to re-enter w/ qwerty, True on mk4 from glob import dis - from ux import ux_show_story MAX_LINES = 7 # without scroll can_scroll = False diff --git a/testing/test_backup.py b/testing/test_backup.py index 3304e65f..e293dc6e 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -2,23 +2,104 @@ # # Testing backups. # -import pytest, time, json, os, shutil +import pytest, time, json, os, shutil, re from constants import simulator_fixed_words, simulator_fixed_tprv from charcodes import KEY_QR from bip32 import BIP32Node from mnemonic import Mnemonic +@pytest.fixture +def override_bkpw(goto_home, pick_menu_item, cap_story, need_keypress, seed_story_to_words, + cap_menu, press_select, press_cancel, enter_complex, is_q1): + + def purge_current(exit=False): + time.sleep(.1) + title, story = cap_story() + if "(1) to forget current" in story: + need_keypress("1") + time.sleep(.1) + title, story = cap_story() + assert "Delete current stored password?" in story + press_select() + time.sleep(.1) + title, story = cap_story() + assert "(1) to forget current" not in story + if exit: + press_cancel() + + def doit(password=None, old_password=None): + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Danger Zone") + pick_menu_item("I Am Developer.") + pick_menu_item("BKPW Override") + time.sleep(.1) + title, story = cap_story() + current_bkpw = None + if "(2) to show current active backup password" in story: + need_keypress("2") + time.sleep(.1) + title, story = cap_story() + assert 'Anyone with knowledge of the password will be able to decrypt your backups.' in story + press_select() + time.sleep(.1) + title, current_bkpw = cap_story() + current_bkpw = current_bkpw.strip() + press_select() + + if old_password: + assert current_bkpw == old_password, "old_password mismatch" + + if password is None: + # purge current bkpw + purge_current(exit=True) + return + + # purge what was there from before + purge_current() + + need_keypress("0") + enter_complex(password, apply=False, b39pass=False) + + time.sleep(.1) + title, story = cap_story() + assert "(2) to show current active backup password" in story + need_keypress("2") + press_select() # are you sure? + time.sleep(.1) + title, story = cap_story() + new_current_bkpw = story.strip() + press_select() + + time.sleep(.1) + title, story = cap_story() + if ((3*" ") in password) and not is_q1: + assert password.replace(" ", " ") == new_current_bkpw + else: + assert new_current_bkpw == password + + assert "(1) to forget current password" in story + assert "(0) to change" in story + + return doit + @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, seed_story_to_words, press_cancel, is_q1, press_select, is_headless): - def doit(reuse_pw=False, save_pw=False, st=None, ct=False): + def doit(reuse_pw=None, 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))) + if isinstance(reuse_pw, list): + assert len(reuse_pw) == 12 + else: + assert reuse_pw is True # default + reuse_pw = ['zoo' for _ in range(12)] + + settings_set('bkpw', ' '.join(reuse_pw)) else: settings_remove('bkpw') @@ -55,13 +136,10 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, return # nothing more to be done if reuse_pw: - assert ' 1: zoo' in body - assert '12: zoo' in body + assert (' 1: %s' % reuse_pw[0]) in body + assert ('12: %s' % reuse_pw[-1]) in body press_select() words = ['zoo'] * 12 - - time.sleep(0.1) - title, body = cap_story() else: assert title == 'NO-TITLE' assert 'Record this' in body @@ -102,7 +180,7 @@ def backup_system(settings_set, settings_remove, goto_home, pick_menu_item, @pytest.mark.qrcode @pytest.mark.parametrize('multisig', [False, 'multisig']) @pytest.mark.parametrize('st', ["b39pass", "eph", None]) -@pytest.mark.parametrize('reuse_pw', [False, True]) +@pytest.mark.parametrize('reuse_pw', [True, False]) @pytest.mark.parametrize('save_pw', [False, True]) @pytest.mark.parametrize('seedvault', [False, True]) @pytest.mark.parametrize('pass_way', ["qr", None]) @@ -147,6 +225,10 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre time.sleep(.1) assert len(get_setting('multisig')) == 1 + if not reuse_pw: + # drop saved bkpw before we get to ephemeral settings + settings_remove("bkpw") + if st == "b39pass": xfp_pass = set_bip39_pw("coinkite", reset=False, seed_vault=seedvault) assert not get_setting('multisig', None) @@ -441,7 +523,6 @@ def test_seed_vault_backup(settings_set, reset_seed_words, generate_ephemeral_wo assert "Press (1) to save" in body press_cancel() time.sleep(.01) - assert get_setting('bkpw', 'xxx') == 'xxx' title, story = cap_story() assert "Backup file written:" in story fn = story.split("\n\n")[1] @@ -516,4 +597,40 @@ def test_clone_start(reset_seed_words, pick_menu_item, cap_story, goto_home): # TODO check file made is a good backup, with correct password +def test_bkpw_override(reset_seed_words, override_bkpw, goto_home, pick_menu_item, + cap_story, press_select, garbage_collector, microsd_path): + reset_seed_words() # clean slate + old_pw = None + test_cases = [ + " ".join(12 * ["elevator"]), + " ".join(12 * ["fever"]), + 32 * "a", + (16 * "0") + " " + (16 *"1"), + 64 * "Q", + (26 * "?") + "!@#$%^&*()", + ] + for pw in test_cases: + override_bkpw(pw, old_pw) + + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Backup") + pick_menu_item("Backup System") + time.sleep(1) + title, story = cap_story() + split_pw = pw.split(" ") + if len(split_pw) == 12: + assert (' 1: %s' % split_pw[0]) in story + assert ('12: %s' % split_pw[-1]) in story + else: + # not words of len 12 + assert ("%s...%s" % (pw[0], pw[-1])) in story + + press_select() + time.sleep(1) + title, story = cap_story() + assert "Backup file written" in story + garbage_collector.append(microsd_path(story.split("\n\n")[1])) + press_select() + # EOF