diff --git a/releases/ChangeLog.md b/releases/ChangeLog.md index 7f320a1e..a5e7e982 100644 --- a/releases/ChangeLog.md +++ b/releases/ChangeLog.md @@ -6,6 +6,7 @@ ([nLockTime](https://en.bitcoin.it/wiki/NLockTime), [nSequence](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki)) when signing +- Enhancement: New submenu for saved BIP-39 Passphrases allowing to delete saved entries. - Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. If current seed is temporary and not saved yet, `Add current tmp` menu item is shown in Seed Vault menu. @@ -20,6 +21,7 @@ - Bugfix: Add missing First Time UX for extended key import as master seed - Bugfix: Hide `Upgrade Firmware` menu item if temporary seed is active - Bugfix: Disallow using master seed as temporary seed +- Bugfix: Do not allow to `APPLY` empty BIP-39 passphrase ## 5.2.0 - 2023-10-10 diff --git a/shared/actions.py b/shared/actions.py index 88f79c45..ec107469 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -980,7 +980,7 @@ async def restore_main_secret(*a): msg = "Restore main wallet and its settings?\n\n" if not in_seed_vault(pa.tmp_value): msg += ( - "Press OK to forget current temporary wallet " + "Press OK to forget current temporary seed " "settings, or press (1) to save & keep " "those settings if same seed is later restored." ) diff --git a/shared/pwsave.py b/shared/pwsave.py index 64c5ed9e..05746845 100644 --- a/shared/pwsave.py +++ b/shared/pwsave.py @@ -2,10 +2,11 @@ # # pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired) # -import stash, ujson, ngu, pyb +import stash, ujson, ngu, pyb, os from files import CardSlot, CardMissingError, needs_microsd from ux import ux_dramatic_pause, ux_confirm, ux_show_story from utils import xfp2str +from menu import MenuItem, MenuSystem class PassphraseSaver: @@ -16,7 +17,8 @@ class PassphraseSaver: def __init__(self): self.key = None - def filename(self, card): + @staticmethod + def filename(card): # Construct actual filename to use. # - some very minor obscurity, but we aren't relying on that. return card.get_sd_root() + '/.tmp.tmp' @@ -31,7 +33,6 @@ class PassphraseSaver: with stash.SensitiveValues(bypass_tmp=True) as sv: self.key = bytearray(sv.encryption_key(salt)) - def _read(self, card): # Return a list of saved passphrases, or empty list if fail. # Fail silently in all cases. Expect to see lots of noise here. @@ -46,9 +47,7 @@ class PassphraseSaver: except: return [] - - async def append(self, xfp, bip39pw): - # encrypt and save; always appends. + async def read_and_save(self): from glob import dis while 1: @@ -59,8 +58,7 @@ class PassphraseSaver: self._calc_key(card) data = self._read(card) if self.key else [] - - data.append(dict(xfp=xfp, pw=bip39pw)) + yield data # yield data that can be modified encrypt = ngu.aes.CTR(self.key) @@ -74,29 +72,112 @@ class PassphraseSaver: except CardMissingError: ch = await needs_microsd() - if ch == 'x': # undocumented, but needs escape route + if ch == 'x': # undocumented, but needs escape route break - - def make_menu(self): - from menu import MenuItem, MenuSystem + async def delete(self, idx): + c = self.read_and_save() + data = next(c) + del data[idx] + # resume generator - save + try: + next(c) + except StopIteration: pass + if not data: + return True + + async def append(self, xfp, bip39pw): + c = self.read_and_save() + data = next(c) + to_add = dict(xfp=xfp, pw=bip39pw) + if to_add not in data: + data.append(to_add) + # resume generator - save + try: + next(c) + except StopIteration: pass + + +class PassphraseSaverMenu(MenuSystem): + + def update_contents(self): + tmp = PassphraseSaverMenu.construct() + self.replace_items(tmp) + + @staticmethod + async def apply(menu, idx, item): + # apply the password immediately and drop them at top menu from actions import goto_top_menu from ux import ux_show_story from seed import set_bip39_passphrase + from pincodes import pa + from glob import settings + bypass_tmp = True + pw, expect_xfp = item.arg + if pa.tmp_value and settings.get("words", None): + xfp = settings.get("xfp", 0) + title = "[%s]" % xfp2str(xfp) + ch = await ux_show_story("Temporary seed is active. Press (1)" + " to add passphrase to the current active" + " temporary seed instead of the main seed.", + title=title, escape='1') + if ch == '1': + bypass_tmp = False + + applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp, + summarize_ux=False) + if not applied: + return + + xfp = settings.get('xfp') + + # verification step + if xfp == expect_xfp: + # feedback that it worked + await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp)) + else: + got = xfp2str(xfp) + exp = xfp2str(expect_xfp) + await ux_show_story("XFP verification failed. Restored wallet XFP [%s] " + "does not match expected XFP [%s] from " + "saved passphrase file." % (got, exp)) + return + + goto_top_menu() + + @staticmethod + async def delete_entry(menu, idx, item): + from ux import the_ux + pw_saver, i = item.arg + if await ux_confirm("Delete saved passphrase?"): + is_empty = await pw_saver.delete(i) + the_ux.pop() + if not is_empty: + m = the_ux.top_of_stack() + m.update_contents() + else: + # remove .tmp.tmp file after last passphrase + # is deleted + with CardSlot() as card: + f_path = pw_saver.filename(card) + os.remove(f_path) + the_ux.pop() + m = the_ux.top_of_stack() + m.update_contents() + + @classmethod + def construct(cls): + # We have a list of xfp+pw fields. Make a menu. # Read file, decrypt and make a menu to show; OR return None # if any error hit. + pw_saver = PassphraseSaver() with CardSlot() as card: - - self._calc_key(card) - if not self.key: return None - - data = self._read(card) + pw_saver._calc_key(card) + data = pw_saver._read(card) if not data: return None - # We have a list of xfp+pw fields. Make a menu. - # Challenge: we need to hint at which is which, but don't want to # show the password on-screen. # - simple algo: @@ -118,46 +199,16 @@ class PassphraseSaver: # give up: show it all! parts = [i for i,_ in pws] - async def doit(menu, idx, item): - # apply the password immediately and drop them at top menu - from pincodes import pa - from glob import settings - - bypass_tmp = True - pw, expect_xfp = item.arg - if pa.tmp_value and settings.get("words", None): - xfp = settings.get("xfp", None) - title = "[%s]" % xfp2str(xfp) - ch = await ux_show_story("Temporary wallet is active. Press (1)" - " to add passphrase to the current active" - " temporary seed instead of the main seed.", - title=title, escape='1') - if ch == '1': - bypass_tmp = False - - applied = await set_bip39_passphrase(pw, bypass_tmp=bypass_tmp, - summarize_ux=False) - if not applied: - return - - xfp = settings.get('xfp') - - # verification step - if xfp == expect_xfp: - # feedback that it worked - await ux_show_story("Passphrase restored.", title="[%s]" % xfp2str(xfp)) - else: - got = xfp2str(xfp) - exp = xfp2str(expect_xfp) - await ux_show_story("XFP verification failed. Restored wallet XFP [%s] " - "does not match expected XFP [%s] from " - "saved passphrase file." % (got, exp)) - return - - goto_top_menu() - - - return MenuSystem((MenuItem(label or '(empty)', f=doit, arg=pw) for pw, label in zip(pws, parts))) + items = [] + for i, (pw, label) in enumerate(zip(pws, parts)): + xfp_ui = "[%s]" % xfp2str(pw[1]) + submenu = MenuSystem([ + MenuItem(xfp_ui), + MenuItem("Restore", f=cls.apply, arg=pw), + MenuItem("Delete", f=cls.delete_entry, arg=(pw_saver, i)), + ]) + items.append(MenuItem(label or "(empty)", menu=submenu)) + return items # # Support for using MicroSD as second factor to the login PIN. diff --git a/shared/seed.py b/shared/seed.py index ff8c1ba4..d07941ac 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -19,7 +19,7 @@ from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code from actions import goto_top_menu from stash import SecretStash from ubinascii import hexlify as b2a_hex -from pwsave import PassphraseSaver +from pwsave import PassphraseSaver, PassphraseSaverMenu from glob import settings, dis from pincodes import pa from nvstore import SettingsObject @@ -1075,6 +1075,14 @@ class PassphraseMenu(MenuSystem): global pp_sofar pp_sofar = '' + items = self.construct() + super(PassphraseMenu, self).__init__(items) + + def update_contents(self): + tmp = self.construct() + self.replace_items(tmp) + + def construct(self): items = [ # xxxxxxxxxxxxxxxx MenuItem('Edit Phrase', f=self.view_edit_phrase), @@ -1090,18 +1098,18 @@ class PassphraseMenu(MenuSystem): with CardSlot() as card: # check if passphrases file exists on SD # if yes add menu item - if card.exists(PassphraseSaver().filename(card)): + if card.exists(PassphraseSaver.filename(card)): items.insert(0, MenuItem('Restore Saved', menu=self.restore_saved)) except: pass - super(PassphraseMenu, self).__init__(items) + return items @staticmethod async def restore_saved(*a): dis.fullscreen("Decrypting...") try: - menu = PassphraseSaver().make_menu() + items = PassphraseSaverMenu.construct() except CardMissingError: await needs_microsd() return @@ -1109,11 +1117,11 @@ class PassphraseMenu(MenuSystem): await ux_show_story(title="Failure", msg=str(e) + problem_file_line(e)) return - if not menu: + if not items: await ux_show_story("Nothing found") return - return menu + return PassphraseSaverMenu(items) def on_cancel(self): # zip to cancel item when they fail to exit via X button @@ -1177,12 +1185,15 @@ class PassphraseMenu(MenuSystem): goto_top_menu() async def done_apply(self, *a): - # apply the passphrase. - # - important to work on empty string here too. + # apply the passphrase import stash from glob import settings from pincodes import pa + if not pp_sofar: + # empty string here - noop + return + nv, xfp, parent_xfp = await calc_bip39_passphrase(pp_sofar, bypass_tmp=True) msg = ('Above is the master key fingerprint of the new wallet. ' diff --git a/testing/data/pwsave.tmp b/testing/data/pwsave.tmp new file mode 100644 index 00000000..8b79c73c Binary files /dev/null and b/testing/data/pwsave.tmp differ diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 11e34076..1ceccd59 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -189,10 +189,10 @@ def restore_main_seed(goto_home, pick_menu_item, cap_story, cap_menu, assert "Restore main wallet and its settings?" in story if seed_vault: - assert "Press OK to forget current temporary wallet " not in story + assert "Press OK to forget current temporary seed " not in story assert "settings, or press (1) to save & keep " not in story else: - assert "Press OK to forget current temporary wallet " in story + assert "Press OK to forget current temporary seed " in story assert "settings, or press (1) to save & keep " in story assert "those settings if same seed is later restored." in story if preserve_settings: diff --git a/testing/test_pwsave.py b/testing/test_pwsave.py index 9ffdb107..42af324d 100644 --- a/testing/test_pwsave.py +++ b/testing/test_pwsave.py @@ -2,9 +2,9 @@ # # tests for ../shared/pwsave.py # -import pytest, time, os +import pytest, time, os, shutil from test_ux import word_menu_entry, enter_complex -from binascii import b2a_hex, a2b_hex +from binascii import a2b_hex from constants import simulator_fixed_tprv SIM_FNAME = '../unix/work/MicroSD/.tmp.tmp' @@ -36,7 +36,6 @@ def get_to_pwmenu(cap_story, need_keypress, goto_home, pick_menu_item): @pytest.mark.parametrize('pws', [ 'abc abc def def 123', - 'empty', '1 2 3', '1 2 3 11 22 33', '1aa1 2aa2 1aa2 2aa1', @@ -54,9 +53,6 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, enter_complex pws = pws.split() xfps = {} - if pws[0] == 'empty': - pws.append('') - uniq = [] for pw in pws: if pw not in uniq: @@ -64,11 +60,7 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, enter_complex get_to_pwmenu() - if pw == '': - pick_menu_item('Add Word') - need_keypress('x') - else: - enter_complex(pw) + enter_complex(pw) pick_menu_item('APPLY') @@ -87,7 +79,6 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, enter_complex pick_menu_item('Restore Saved') m = cap_menu() - #print(m) assert len(m) == len(uniq) if len(pw): @@ -97,6 +88,11 @@ def test_first_time(pws, need_keypress, cap_story, pick_menu_item, enter_complex assert set(i[-1] for i in m) == set(j[-1] if j else ')' for j in pws) pick_menu_item(m[n]) + time.sleep(.1) + sub_menu = cap_menu() + assert len(sub_menu) == 3 # xfp label, restore, delete + assert xfps[uniq[n]] in sub_menu[0] + pick_menu_item("Restore") time.sleep(.01) title, story = cap_story() @@ -126,7 +122,7 @@ p=PassphraseSaver(); p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()''' # recalc what it should be from pycoin.key.BIP32Node import BIP32Node - from pycoin.encoding import from_bytes_32, to_bytes_32 + from pycoin.encoding import to_bytes_32 from hashlib import sha256 mk = BIP32Node.from_wallet_key(simulator_fixed_tprv) @@ -159,5 +155,34 @@ p=PassphraseSaver(); p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()''' assert j[0]['pw'] assert j[0]['xfp'] +def test_delete_one_by_one(get_to_pwmenu, pick_menu_item, cap_menu, + cap_story, need_keypress): + # delete it one by one + # when all deleted - we must be back in Passphrase + # menu without Restore Saved option visible + get_to_pwmenu() + time.sleep(.1) + m = cap_menu() + if 'Restore Saved' not in m: + shutil.copy2('data/pwsave.tmp', '../unix/work/MicroSD/.tmp.tmp') + get_to_pwmenu() + pick_menu_item('Restore Saved') + m = cap_menu() + len_m = len(m) + for i, mi in enumerate(m): + pick_menu_item(mi) + pick_menu_item("Delete") + time.sleep(.1) + _, story = cap_story() + assert "Delete saved passphrase?" in story + need_keypress("y") + mm = cap_menu() + if i == (len_m - 1): + # last item - back to passphrase menu + assert "Edit Phrase" in mm + assert "Restore Saved" not in mm + else: + assert mi not in mm + assert "Edit Phrase" not in mm # EOF