pwsave menu UX rework; do not allow empty bip39 passphrase

This commit is contained in:
scgbckbone 2023-11-08 11:47:35 +01:00 committed by doc-hex
parent 79c143b7eb
commit 3e5fd573a6
7 changed files with 172 additions and 83 deletions

View File

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

View File

@ -972,7 +972,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."
)

View File

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

View File

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

BIN
testing/data/pwsave.tmp Normal file

Binary file not shown.

View File

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

View File

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