From dc216ff08194cb0c0a2adb9bb89d33faf4159b1e Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Wed, 8 Nov 2023 11:47:35 +0100 Subject: [PATCH] pwsave menu UX rework; do not allow empty bip39 passphrase (cherry picked from commit 3e5fd573a6af3f5f46d826fa0a8013a5ee0772a9) --- releases/ChangeLog.md | 2 + shared/actions.py | 2 +- shared/pwsave.py | 171 +++++++++++++++++++++++++------------- shared/seed.py | 30 ++++--- testing/data/pwsave.tmp | Bin 0 -> 191 bytes testing/test_ephemeral.py | 4 +- testing/test_pwsave.py | 51 +++++++++--- 7 files changed, 174 insertions(+), 86 deletions(-) create mode 100644 testing/data/pwsave.tmp 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 6d233424..b2983d96 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -930,7 +930,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 034e2745..97c87d7b 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 +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,32 +72,115 @@ 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: + # - simple algo: # - show either first N or last N chars only # - pick which set which is all-unique, if neither, try N+1 # @@ -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 76a32924..a71f48ee 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 @@ -1084,6 +1084,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): if version.has_qwerty: items = [ MenuItem('Edit Phrase', f=self.view_edit_phrase, shortcut=KEY_QR), @@ -1101,25 +1109,24 @@ class PassphraseMenu(MenuSystem): MenuItem('APPLY', f=self.done_apply), MenuItem('CANCEL', f=self.done_cancel), ] - - # quick SD card check: will use A if both slots are stuffed + # quick SD card check if pyb.SDCard().present(): try: 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 @@ -1127,11 +1134,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 @@ -1196,12 +1203,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 0000000000000000000000000000000000000000..8b79c73caf9496a9c91ef78feca7d65dd2c41416 GIT binary patch literal 191 zcmV;w06_on$^QWJH)Gw4D7Q|RUn+Gi_(d?aT4&$HsV)Ho^gA$a`e-I?_?}0t{YQ3W z@S>rSm+KXj9s&Y9ZzZQg`U=MsjG(GI;LbrP`?xUVvlV4`RA#r}00K_K#7{g9f!T53 ziNXEa6ADEH6XHPxnh_)KH)$uPn0mf-gxDt|5HndF##g%v