pwsave menu UX rework; do not allow empty bip39 passphrase
(cherry picked from commit 3e5fd573a6)
This commit is contained in:
parent
e0afee6b13
commit
dc216ff081
@ -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
|
||||
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
171
shared/pwsave.py
171
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.
|
||||
|
||||
@ -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. '
|
||||
|
||||
BIN
testing/data/pwsave.tmp
Normal file
BIN
testing/data/pwsave.tmp
Normal file
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user