1461 lines
47 KiB
Python
1461 lines
47 KiB
Python
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# seed.py - bip39 seeds and words
|
|
#
|
|
# references:
|
|
# - <https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki>
|
|
# - <https://iancoleman.io/bip39/#english>
|
|
# - zero values:
|
|
# - 'abandon' * 23 + 'art'
|
|
# - 'abandon' * 17 + 'agent'
|
|
# - 'abandon' * 11 + 'about'
|
|
#
|
|
import ngu, uctypes, bip39, random, version, ure, chains
|
|
from ucollections import OrderedDict
|
|
from menu import MenuItem, MenuSystem
|
|
from utils import xfp2str, swab32
|
|
from utils import deserialize_secret, problem_file_line, wipe_if_deltamode
|
|
from uhashlib import sha256
|
|
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X
|
|
from ux import PressRelease, ux_input_text, show_qr_code
|
|
from actions import goto_top_menu
|
|
from stash import SecretStash, SensitiveValues
|
|
from ubinascii import hexlify as b2a_hex
|
|
from pwsave import PassphraseSaver, PassphraseSaverMenu
|
|
from glob import settings, dis
|
|
from pincodes import pa
|
|
from nvstore import SettingsObject
|
|
from files import CardMissingError, needs_microsd
|
|
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_NFC
|
|
from uasyncio import sleep_ms
|
|
from ucollections import namedtuple
|
|
|
|
# seed words lengths we support: 24=>256 bits, and recommended
|
|
VALID_LENGTHS = (24, 18, 12)
|
|
|
|
# bit flag that means "also include bare prefix as a valid word"
|
|
_PREFIX_MARKER = const(1<<26)
|
|
|
|
# what we store (in JSON as a tuple) for each seed vault key.
|
|
# - 'encoded' is hex, and has is trimmed of right side zeros
|
|
VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin')
|
|
|
|
def not_hobbled_mode():
|
|
# used as menu predicate and similar
|
|
return not pa.hobbled_mode
|
|
|
|
def seed_vault_iter():
|
|
# iterate over all seeds in the vault; returns VaultEntry instances.
|
|
# raw vault entries are list type when json.loaded from flash
|
|
for lst in settings.master_get("seeds", []):
|
|
yield VaultEntry(*lst)
|
|
|
|
def letter_choices(sofar='', depth=0, thres=5):
|
|
# make a list of word completions based on indicated prefix
|
|
if not sofar:
|
|
# all letters:
|
|
# - except 'x' which isn't used in the wordlist.
|
|
# - and q- which is really qu-, because English.
|
|
return [('%s-' % chr(97+i)) if i != 16 else 'qu-' for i in range(26) if i != 23]
|
|
|
|
exact, nexts, matched = bip39.next_char(sofar)
|
|
#print("[%d] %s => x=%r n=%r m=%r" % (depth, sofar, exact, nexts, matched))
|
|
|
|
if not nexts:
|
|
# no more choices; done
|
|
return [matched]
|
|
|
|
rv = []
|
|
if exact:
|
|
# ie: "act" plus "action", "actor"
|
|
rv.append(sofar)
|
|
|
|
if len(nexts) == 1 and matched:
|
|
# aba => abandon (unambig first 3 chars)
|
|
# but not: age => age, agent (abig first 3)
|
|
rv.append(matched)
|
|
else:
|
|
for w in nexts:
|
|
rv.append(sofar + w + '-')
|
|
|
|
# replace bab- => baby and other cases where prefix is unique
|
|
# - doesn't grow menu length
|
|
if len(sofar) >= 2:
|
|
for n, w in enumerate(rv):
|
|
if w[-1] != '-': continue
|
|
exact, nexts, matched = bip39.next_char(w[:-1])
|
|
if matched:
|
|
rv[n] = matched
|
|
|
|
if len(rv) <= thres:
|
|
if depth == 0:
|
|
# examples:
|
|
# z => ze- and zo- ... better if all 4 z-words are shown
|
|
# y => 6 choices
|
|
# - above thres=5, we get menus w/60+ entries
|
|
# - recurse only one level also to keep size down
|
|
a = []
|
|
for i in rv:
|
|
if i[-1] != '-':
|
|
a.append(i)
|
|
else:
|
|
a.extend(letter_choices(i[:-1], depth+1))
|
|
return a
|
|
|
|
return rv
|
|
|
|
'''
|
|
# 100% working test code (keep)
|
|
|
|
thres=2 => min/max/avg = 2 / 20 / 4.221338 nodes=628
|
|
thres=3 => min/max/avg = 2 / 20 / 4.371667 nodes=600
|
|
thres=4 => min/max/avg = 2 / 31 / 4.593250 nodes=563
|
|
thres=5 => min/max/avg = 2 / 34 / 4.882917 nodes=521
|
|
thres=6 => min/max/avg = 2 / 58 / 5.397826 nodes=460
|
|
thres=7 => min/max/avg = 2 / 61 / 5.618721 nodes=438
|
|
thres=8 => min/max/avg = 2 / 61 / 5.793839 nodes=422
|
|
thres=9 => min/max/avg = 2 / 66 / 5.485588 nodes=451
|
|
|
|
def test_lc():
|
|
for thres in range(2, 10):
|
|
terms = set()
|
|
todo = set(letter_choices(''))
|
|
sizes = []
|
|
while todo:
|
|
w = todo.pop()
|
|
assert w not in terms
|
|
if w[-1] == '-':
|
|
h = letter_choices(w[:-1], thres=thres)
|
|
assert len(h) == len(set(h))
|
|
sizes.append(len(h))
|
|
todo.update(h)
|
|
else:
|
|
terms.add(w)
|
|
|
|
assert len(terms) == 2048
|
|
|
|
print("thres=%d => min/max/avg = %d / %d / %f nodes=%d" %
|
|
(thres, min(sizes), max(sizes), sum(sizes)/len(sizes), len(sizes)))
|
|
'''
|
|
|
|
async def commit_new_words(new_words):
|
|
# save the new seed value
|
|
set_seed_value(new_words)
|
|
|
|
# clear menu stack
|
|
goto_top_menu(first_time=True)
|
|
|
|
|
|
class WordNestMenu(MenuSystem):
|
|
# singleton (cls level) vars
|
|
words = []
|
|
target_words = None
|
|
has_checksum = True
|
|
done_cb = None
|
|
|
|
def __init__(self, num_words=None, has_checksum=True, done_cb=commit_new_words,
|
|
items=None, is_commit=False, menu_cbf=None, prefix="", words=None):
|
|
|
|
if num_words is not None:
|
|
WordNestMenu.target_words = num_words
|
|
WordNestMenu.has_checksum = has_checksum
|
|
WordNestMenu.words = []
|
|
WordNestMenu.done_cb = done_cb
|
|
is_commit = True
|
|
|
|
if words:
|
|
WordNestMenu.words = words
|
|
|
|
if not items:
|
|
ch = letter_choices(prefix)
|
|
if menu_cbf:
|
|
items = [MenuItem(i, f=menu_cbf) for i in ch]
|
|
else:
|
|
items = [MenuItem(i, menu=self.next_menu) for i in ch]
|
|
|
|
self.is_commit = is_commit
|
|
|
|
super(WordNestMenu, self).__init__(items)
|
|
|
|
@classmethod
|
|
async def get_n_words(cls, num_words):
|
|
rv = []
|
|
for _ in range(num_words):
|
|
rv = await cls.get_word(rv, num_words)
|
|
|
|
return rv
|
|
|
|
@classmethod
|
|
async def get_word(cls, words=None, target_words=None):
|
|
# Just block until N words are provided. May only work before menus start?
|
|
from glob import numpad
|
|
|
|
async def menu_done_cbf(menu, b, c):
|
|
# duplicates some of the logic of next_menu
|
|
if c.label[-1] == '-':
|
|
lc = c.label[0:-1]
|
|
else:
|
|
cls.words.append(c.label)
|
|
numpad.abort_ux()
|
|
return
|
|
|
|
m = cls(prefix=lc, menu_cbf=menu_done_cbf)
|
|
the_ux.push(m)
|
|
await the_ux.interact()
|
|
|
|
m = cls(num_words=target_words, menu_cbf=menu_done_cbf, has_checksum=False, words=words)
|
|
|
|
the_ux.push(m)
|
|
await the_ux.interact()
|
|
|
|
return cls.words
|
|
|
|
@staticmethod
|
|
async def next_menu(self, idx, choice):
|
|
|
|
words = WordNestMenu.words
|
|
cls = self.__class__
|
|
|
|
if choice.label[-1] == '-':
|
|
ch = letter_choices(choice.label[0:-1])
|
|
|
|
return cls(items=[MenuItem(i, menu=self.next_menu) for i in ch])
|
|
|
|
# terminal choice, start next word
|
|
words.append(choice.label)
|
|
|
|
assert len(words) <= self.target_words
|
|
|
|
if self.has_checksum and len(words) == (self.target_words - 1):
|
|
# we can provide final choices, but only for 18- and 24-word cases
|
|
# - otherwise, make new menu tree w/ all possible choices (128 total)
|
|
final_words = list(bip39.a2b_words_guess(words))
|
|
|
|
async def picks_chk_word(s, idx, choice):
|
|
# they picked final word, the word includes valid checksum bits
|
|
words.append(choice.label)
|
|
await cls.done_cb(words.copy())
|
|
|
|
if len(final_words) <= 32:
|
|
# 18 or 24 word cases => 32 or 8 choices are valid
|
|
items = [MenuItem(w, f=picks_chk_word) for w in final_words]
|
|
items.append(MenuItem('(none above)', f=self.explain_error))
|
|
return cls(is_commit=True, items=items)
|
|
|
|
# 12 words => 128 valid final words
|
|
# show start letter and under that valid words
|
|
d = OrderedDict()
|
|
for w in final_words:
|
|
if w[0] not in d:
|
|
d[w[0]] = []
|
|
d[w[0]].append(w)
|
|
|
|
items = []
|
|
for s, w_lst in sorted(d.items()):
|
|
sub_items = [MenuItem(w, f=picks_chk_word) for w in w_lst]
|
|
sub_items.append(MenuItem('(none above)', f=self.explain_error))
|
|
items.append(MenuItem(s+"-", menu=cls(items=sub_items)))
|
|
|
|
return cls(is_commit=True, items=items)
|
|
|
|
if len(words) == self.target_words:
|
|
return await cls.done_cb(words.copy())
|
|
|
|
# pop stack to reset depth, and start again at a- .. z-
|
|
cls.pop_all()
|
|
return cls(items=None, is_commit=True)
|
|
|
|
@classmethod
|
|
def pop_all(cls):
|
|
while isinstance(the_ux.top_of_stack(), cls):
|
|
the_ux.pop()
|
|
|
|
async def on_cancel(self):
|
|
# user pressed cancel on a menu (so he's going upwards)
|
|
# - if it's a step where we added to the word list, undo that.
|
|
# - but keep them in our system until:
|
|
# - when the word list is empty and they cancel, stop
|
|
words = WordNestMenu.words
|
|
|
|
if self.is_commit and words:
|
|
words.pop()
|
|
|
|
# replace the menu we are show w/ top-level (a-) menu
|
|
the_ux.pop()
|
|
nxt = WordNestMenu(is_commit=True)
|
|
the_ux.push(nxt)
|
|
else:
|
|
the_ux.pop()
|
|
|
|
async def explain_error(self, *a):
|
|
|
|
await ux_show_story('''\
|
|
You've got a mistake in your words. We know because the checksum does not \
|
|
verify. It's probably best to start over again, but you can back out \
|
|
individual words if you wish.''')
|
|
|
|
async def start_over(self, *a):
|
|
|
|
# pop everything we've done off the stack
|
|
self.pop_all()
|
|
|
|
# begin again, empty but same settings
|
|
self.words = []
|
|
the_ux.push(self.__class__(num_words=WordNestMenu.target_words))
|
|
|
|
def tr_label(self):
|
|
return 'Word'
|
|
|
|
def late_draw(self, dis):
|
|
# add an overlay with "word N" in small text, top right.
|
|
if dis.has_lcd: return # unreachable anyway?
|
|
|
|
from display import FontTiny
|
|
|
|
count = len(self.words)
|
|
if count >= self.target_words:
|
|
# on final DONE/incorrect screen
|
|
return
|
|
|
|
dis.progress_bar(count / self.target_words)
|
|
|
|
count += 1
|
|
invert = (self.cursor == self.ypos)
|
|
|
|
y = 6
|
|
dis.text(-8, y-4, "%d" % count, invert=invert)
|
|
dis.text(-18-(6 if count >= 10 else 0), y, self.tr_label(), FontTiny, invert=invert)
|
|
|
|
|
|
async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False):
|
|
from ux import ux_render_words
|
|
from glob import NFC
|
|
|
|
if prompt:
|
|
title = None
|
|
msg = prompt
|
|
else:
|
|
m = 'Record these %d secret words!' % len(words)
|
|
title, msg = (m, "") if version.has_qwerty else (None, m+"\n")
|
|
|
|
msg += ux_render_words(words)
|
|
|
|
msg += '\n\nPlease check and double check your notes.'
|
|
if not ephemeral:
|
|
# user can skip quiz for ephemeral secrets
|
|
msg += " There will be a test!"
|
|
|
|
escape = (escape or '') + '1'
|
|
if not version.has_qwerty:
|
|
title = None
|
|
extra += 'Press (1) to view as QR Code'
|
|
if NFC:
|
|
extra += ", (3) to share via NFC"
|
|
escape += "3"
|
|
extra += "."
|
|
|
|
if extra:
|
|
msg += '\n\n'
|
|
msg += extra
|
|
|
|
while 1:
|
|
rv = ' '.join(w[0:4] for w in words)
|
|
ch = await ux_show_story(msg, title=title, escape=escape, sensitive=True,
|
|
hint_icons=KEY_QR+(KEY_NFC if NFC else ''))
|
|
if ch in ('1'+KEY_QR):
|
|
await show_qr_code(rv, True, is_secret=True)
|
|
continue
|
|
if NFC and (ch in "3"+KEY_NFC):
|
|
await NFC.share_text(rv, is_secret=True)
|
|
continue
|
|
|
|
break
|
|
|
|
return ch
|
|
|
|
|
|
async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
|
|
from ux import ux_dice_rolling
|
|
|
|
low_entropy_msg = "You only provided %d dice rolls, and each roll adds only 2.585 bits of entropy."
|
|
low_entropy_msg += " For %d-bit security"
|
|
if nwords is not None:
|
|
# do not add this if we generate private key in paper wallets
|
|
low_entropy_msg += ", which is considered the minimum for %d word seeds," % nwords
|
|
low_entropy_msg += " you need at least %d rolls."
|
|
|
|
# None is for papaer wallet private key - as it is 32 bytes of entropy we need 99 D6
|
|
if nwords in (24, None):
|
|
threshold = 99
|
|
sec_bit = 256
|
|
else:
|
|
threshold = 50
|
|
sec_bit = 128
|
|
|
|
counter = {}
|
|
md = sha256(seed)
|
|
pr = PressRelease()
|
|
|
|
# draws initial screen, and returns funct to update count and/or hash
|
|
screen_updater = ux_dice_rolling()
|
|
redraw = False
|
|
while 1:
|
|
if redraw:
|
|
# redraw basic dice screen after different story was shown
|
|
screen_updater = ux_dice_rolling()
|
|
|
|
# Note: cannot scroll this msg because 5=up arrow
|
|
hx = str(b2a_hex(md.digest()), 'ascii')
|
|
screen_updater(count, hx)
|
|
|
|
ch = await pr.wait()
|
|
|
|
if ch in '123456':
|
|
count += 1
|
|
counter[ch] = counter.get(ch, 0) + 1 # mimics defaultdict
|
|
|
|
# show udpated count immediately
|
|
screen_updater(count, None)
|
|
|
|
# this is slow enough to see
|
|
md.update(ch)
|
|
|
|
elif ch in KEY_CANCEL+"x":
|
|
# Because the change (roll) has already been applied,
|
|
# only let them abort if it's early still
|
|
if count < 10 and judge_them:
|
|
return 0, seed
|
|
elif ch in KEY_ENTER+"y":
|
|
if count < threshold and judge_them:
|
|
if not count:
|
|
return 0, seed
|
|
|
|
story = low_entropy_msg % (count, sec_bit, threshold)
|
|
if enforce:
|
|
ch = await ux_show_story("Not enough dice rolls!!!\n\n" + story +
|
|
"\n\nPress %s to add more dice rolls. %s to exit" % (OK, X))
|
|
if ch == "y":
|
|
redraw = True
|
|
continue
|
|
else:
|
|
return 0, seed
|
|
else:
|
|
ok = await ux_confirm(story)
|
|
if not ok:
|
|
redraw = True
|
|
continue
|
|
|
|
if judge_them:
|
|
bad_dist = any((v / count) > 0.30 for _, v in counter.items())
|
|
if bad_dist:
|
|
bad_dist_msg = ("Distribution of dice rolls is not random. "
|
|
"Some numbers occurred more than 30% of the time.")
|
|
if enforce:
|
|
await ux_show_story(bad_dist_msg)
|
|
return 0, seed # exit
|
|
else:
|
|
ok = await ux_confirm(bad_dist_msg)
|
|
if not ok:
|
|
redraw = True
|
|
continue
|
|
break
|
|
|
|
if count:
|
|
seed = md.digest()
|
|
|
|
return count, seed
|
|
|
|
async def new_from_dice(nwords):
|
|
# Use lots of (D6) dice rolls to create seed entropy.
|
|
# Note: only 2.585 bits of entropy per roll, so need lots!
|
|
# 50 => 128bits, 99 => 256bits
|
|
|
|
seed = b''
|
|
count = 0
|
|
|
|
count, seed = await add_dice_rolls(count, seed, True, nwords, enforce=True)
|
|
if count == 0: return
|
|
|
|
words = await approve_word_list(seed, nwords)
|
|
if words:
|
|
await commit_new_words(words)
|
|
|
|
def in_seed_vault(encoded):
|
|
# Test if indicated secret is in the seed vault already.
|
|
hss = None
|
|
for rec in seed_vault_iter():
|
|
if not hss:
|
|
hss = SecretStash.storage_serialize(encoded)
|
|
if hss == rec.encoded:
|
|
return True
|
|
|
|
return False
|
|
|
|
async def add_seed_to_vault(encoded, origin=None, label=None):
|
|
|
|
if not settings.master_get("seedvault", False):
|
|
# seed vault disabled
|
|
# this can be re-enabled by attacker in deltamode
|
|
return
|
|
if pa.is_secret_blank() or pa.is_deltamode():
|
|
# do not save anything if no SE secret yet
|
|
# do not offer any access to SV in deltamode
|
|
return
|
|
|
|
# do not offer to store secrets that are already in vault
|
|
if in_seed_vault(encoded):
|
|
return
|
|
|
|
# stay "read only" in hobbled mode
|
|
if pa.hobbled_mode:
|
|
return
|
|
|
|
main_xfp = settings.master_get("xfp", 0)
|
|
|
|
# parse encoded
|
|
_,_,node = SecretStash.decode(encoded)
|
|
new_xfp = swab32(node.my_fp())
|
|
new_xfp_str = xfp2str(new_xfp)
|
|
|
|
# do not offer to store main seed
|
|
if new_xfp == main_xfp:
|
|
return
|
|
|
|
seeds = settings.master_get("seeds", [])
|
|
|
|
xfp_ui = "[%s]" % new_xfp_str
|
|
story = ("Press (1) to "
|
|
"store temporary seed into Seed Vault. This way you can easily switch "
|
|
"to this secret and use it as temporary seed in future.\n\nPress %s "
|
|
"to continue without saving." % OK)
|
|
|
|
ch = await ux_show_story(story, escape="1")
|
|
if ch != "1":
|
|
# didn't want to save
|
|
return
|
|
|
|
# Save it into master settings
|
|
rec = VaultEntry(xfp=new_xfp_str, encoded=SecretStash.storage_serialize(encoded),
|
|
label=(label or xfp_ui), origin=origin)
|
|
seeds.append(list(rec))
|
|
|
|
settings.master_set("seeds", seeds)
|
|
|
|
await ux_show_story(xfp_ui + "\nSaved to Seed Vault")
|
|
|
|
return True
|
|
|
|
async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='',
|
|
is_restore=False, origin=None, label=None):
|
|
# Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp.
|
|
if not is_restore and not_hobbled_mode():
|
|
await add_seed_to_vault(encoded, origin=origin, label=label)
|
|
dis.fullscreen("Wait...")
|
|
|
|
applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw)
|
|
|
|
# FYI: Might need to bounce the USB connection, because our pubkey has changed,
|
|
# altho if they have already picked a shared session key, no need, and
|
|
# would only affect MitM test, which has already been done.
|
|
|
|
dis.progress_bar_show(1)
|
|
|
|
if not applied:
|
|
await ux_show_story(title="FAILED", msg=err_msg)
|
|
return
|
|
|
|
xfp = "[" + xfp2str(settings.get("xfp", 0)) + "]"
|
|
if summarize_ux:
|
|
msg = "New temporary master key is in effect now."
|
|
if bip39pw:
|
|
msg += "\n\nPassphrase: %s" % bip39pw
|
|
await ux_show_story(title=xfp, msg=msg)
|
|
|
|
return applied
|
|
|
|
async def set_ephemeral_seed_words(words, origin):
|
|
dis.progress_bar_show(0.1)
|
|
encoded = seed_words_to_encoded_secret(words)
|
|
dis.progress_bar_show(0.5)
|
|
await set_ephemeral_seed(encoded, origin=origin)
|
|
goto_top_menu()
|
|
|
|
async def ephemeral_seed_generate_from_dice(nwords):
|
|
# Use lots of (D6) dice rolls to create seed entropy.
|
|
# Note: only 2.585 bits of entropy per roll, so need lots!
|
|
# 50 => 128bits, 99 => 256bits
|
|
|
|
seed = b''
|
|
count = 0
|
|
|
|
count, seed = await add_dice_rolls(count, seed, True, nwords)
|
|
if count == 0: return
|
|
|
|
words = await approve_word_list(seed, nwords, ephemeral=True)
|
|
if words:
|
|
dis.fullscreen("Applying...")
|
|
await set_ephemeral_seed_words(words, origin='Dice')
|
|
|
|
def generate_seed():
|
|
# Generate 32 bytes of best-quality high entropy TRNG bytes.
|
|
|
|
seed = ngu.random.bytes(32)
|
|
assert len(set(seed)) > 4 # TRNG failure
|
|
|
|
# hash to mitigate any possible bias in TRNG
|
|
return ngu.hash.sha256d(seed)
|
|
|
|
async def make_new_wallet(nwords):
|
|
# Pick a new random seed.
|
|
await ux_dramatic_pause('Generating...', 3)
|
|
seed = generate_seed()
|
|
words = await approve_word_list(seed, nwords)
|
|
if words:
|
|
await commit_new_words(words)
|
|
|
|
|
|
async def ephemeral_seed_import(nwords):
|
|
async def import_done_cb(words):
|
|
dis.fullscreen("Applying...")
|
|
await set_ephemeral_seed_words(words, origin='Imported')
|
|
|
|
if version.has_qwerty:
|
|
from ux_q1 import seed_word_entry
|
|
await seed_word_entry('Ephemeral Seed Words', nwords, done_cb=import_done_cb)
|
|
else:
|
|
return WordNestMenu(nwords, done_cb=import_done_cb)
|
|
|
|
async def ephemeral_seed_generate(nwords):
|
|
await ux_dramatic_pause('Generating...', 3)
|
|
seed = generate_seed()
|
|
words = await approve_word_list(seed, nwords, ephemeral=True)
|
|
if words:
|
|
dis.fullscreen("Applying...")
|
|
await set_ephemeral_seed_words(words, origin="TRNG Words")
|
|
|
|
async def set_seed_extended_key(extended_key):
|
|
encoded, chain = xprv_to_encoded_secret(extended_key)
|
|
set_seed_value(encoded=encoded, chain=chain)
|
|
goto_top_menu(first_time=True)
|
|
|
|
async def set_ephemeral_seed_extended_key(extended_key, origin=None):
|
|
encoded, chain = xprv_to_encoded_secret(extended_key)
|
|
dis.fullscreen("Applying...")
|
|
await set_ephemeral_seed(encoded=encoded, chain=chain, origin=origin)
|
|
goto_top_menu()
|
|
|
|
async def approve_word_list(seed, nwords, ephemeral=False):
|
|
# Force the user to write the seeds words down, give a quiz, then save them.
|
|
|
|
# LESSON LEARNED: if the user is writting down the words, as we have
|
|
# vividly instructed, then it's a big deal to lose those words and have to start
|
|
# over. So confirm that action, and don't volunteer it.
|
|
|
|
if nwords == 12:
|
|
seed = seed[0:16]
|
|
|
|
words = bip39.b2a_words(seed).split(' ')
|
|
assert len(words) == nwords
|
|
extra_msg = 'Press (4) to add some dice rolls into the mix. '
|
|
if ephemeral:
|
|
# document quiz skipping if generating ephemeral seed
|
|
extra_msg += "Press (6) to skip word quiz. "
|
|
|
|
while 1:
|
|
# show the seed words
|
|
ch = await show_words(words, escape='46', extra=extra_msg, ephemeral=ephemeral)
|
|
if ch == 'x':
|
|
# user abort, but confirm it!
|
|
if await ux_confirm("Throw away those words and stop this process?"):
|
|
return
|
|
else:
|
|
continue
|
|
|
|
if ch == '4':
|
|
# dice roll mode
|
|
count, new_seed = await add_dice_rolls(0, seed, False)
|
|
if count:
|
|
seed = new_seed[0:16] if nwords == 12 else new_seed
|
|
words = bip39.b2a_words(seed).split(' ')
|
|
|
|
continue
|
|
|
|
if ch == '6':
|
|
# wants to skip the quiz (undocumented)
|
|
if await ux_confirm("Skipping the quiz means you might have "
|
|
"recorded the seed wrong and will be crying later."):
|
|
break
|
|
|
|
# Perform a test, to check they wrote them down
|
|
ch = await word_quiz(words)
|
|
if ch == 'x':
|
|
# user abort quiz
|
|
if await ux_confirm("Throw away those words and stop this process? "
|
|
"Press %s to see the word list again and restart the quiz." % X):
|
|
return
|
|
|
|
# show the words again, but don't change them
|
|
continue
|
|
|
|
# quiz passed
|
|
break
|
|
|
|
return words
|
|
|
|
def seed_words_to_encoded_secret(words):
|
|
# seed without checksum
|
|
seed = bip39.a2b_words(words) # checksum check
|
|
# encode it for our limited secret space
|
|
nv = SecretStash.encode(seed_phrase=seed)
|
|
return nv
|
|
|
|
def xprv_to_encoded_secret(xprv):
|
|
# read an xprv/tprv/etc and return BIP-32 node and what chain it's on.
|
|
# - can handle any garbage line
|
|
# - returns (node, chain)
|
|
# - people are using SLIP132 so we need this
|
|
ln = xprv.strip()
|
|
pat = ure.compile('.prv[A-Za-z0-9]+')
|
|
found = pat.search(ln)
|
|
assert found, "not extended privkey"
|
|
# serialize, and note version code
|
|
node, chain, addr_fmt, is_private = chains.slip132_deserialize(found.group(0))
|
|
assert node, "wrong extended privkey"
|
|
nv = SecretStash.encode(xprv=node)
|
|
node.blank()
|
|
return nv, chain # need to know chain
|
|
|
|
|
|
def set_seed_value(words=None, encoded=None, chain=None):
|
|
# Save the seed words (or other encoded private key) into secure element.
|
|
# BIP-39 passphrase is not set at this point (empty string).
|
|
if words:
|
|
nv = seed_words_to_encoded_secret(words)
|
|
else:
|
|
nv = encoded
|
|
|
|
from glob import dis
|
|
try:
|
|
dis.fullscreen('Applying...')
|
|
dis.busy_bar(True)
|
|
pa.change(new_secret=nv)
|
|
|
|
# re-read settings since key is now different
|
|
# - also captures xfp, xpub at this point
|
|
pa.new_main_secret(nv, chain=chain)
|
|
|
|
# check and reload secret
|
|
pa.reset()
|
|
pa.login()
|
|
finally:
|
|
dis.busy_bar(False)
|
|
|
|
|
|
async def calc_bip39_passphrase(pw, bypass_tmp=False):
|
|
# Returns (new) encoded secret, new xfp, old xfp
|
|
from glob import dis, settings
|
|
|
|
dis.fullscreen("Working...")
|
|
|
|
current_xfp = settings.get("xfp", 0)
|
|
|
|
with SensitiveValues(bip39pw=pw, bypass_tmp=bypass_tmp) as sv:
|
|
# can't do it without original seed words (late, but caller has checked)
|
|
assert sv.mode == 'words', sv.mode
|
|
nv = SecretStash.encode(xprv=sv.node)
|
|
xfp = swab32(sv.node.my_fp())
|
|
|
|
return nv, xfp, current_xfp
|
|
|
|
async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True):
|
|
nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp)
|
|
|
|
ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw,
|
|
origin="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp))
|
|
|
|
dis.draw_status(bip39=int(bool(pw)), xfp=xfp, tmp=1)
|
|
|
|
return ret
|
|
|
|
async def remember_ephemeral_seed():
|
|
# Compute current xprv and switch to using that as root secret.
|
|
from nvstore import SettingsObject
|
|
from glob import dis
|
|
|
|
# we are already at temporary seed, with correct
|
|
# settings in use - no need to call new_main_secret
|
|
# at the end
|
|
|
|
# locking down temporary as new master
|
|
# old master settings are destroyed
|
|
dis.fullscreen("Cleanup...")
|
|
assert pa.tmp_value, "no tmp"
|
|
assert SettingsObject.master_nvram_key, "master nvram k"
|
|
old_master = SettingsObject(SettingsObject.master_nvram_key)
|
|
old_master.load()
|
|
old_master.blank()
|
|
del old_master
|
|
|
|
# address cache, settings from tmp seeds / seedvault seeds
|
|
# rebuild fs as we want to save current tmp settings immediately
|
|
from files import wipe_flash_filesystem
|
|
wipe_flash_filesystem()
|
|
|
|
dis.draw_status(bip39=0, tmp=0)
|
|
dis.fullscreen('Saving...')
|
|
pa.change(new_secret=pa.tmp_value, tmp_lockdown=True)
|
|
|
|
# not needed - will be handled by reboot
|
|
SettingsObject.master_nvram_key = None
|
|
SettingsObject.master_sv_data = {}
|
|
|
|
# check and reload secret
|
|
pa.reset()
|
|
pa.login()
|
|
|
|
async def restore_to_main_secret(preserve_settings=False):
|
|
# go back to main se2 secret
|
|
pa.new_main_secret(raw_secret=None, blank=not preserve_settings)
|
|
|
|
def clear_seed():
|
|
from glob import dis
|
|
import utime, callgate
|
|
|
|
dis.fullscreen('Clearing...')
|
|
dis.busy_bar(True)
|
|
|
|
# clear settings associated with this key, since it will be no more
|
|
settings.blank()
|
|
|
|
callgate.fast_wipe(True)
|
|
# NOT REACHED
|
|
|
|
async def word_quiz(words, limited=None, title='Word %d is?'):
|
|
# Perform a test, to check they wrote them down
|
|
# Return X if they cancel early.
|
|
# Can just pick a subset # of words, with limited arg.
|
|
|
|
wl = len(words) # 24 or 12, etc.
|
|
|
|
if limited is not None:
|
|
# truncate to some N randomly-selected words in the list
|
|
# and always the last word
|
|
order = list(range(wl-1))
|
|
random.shuffle(order)
|
|
|
|
order = order[0:limited-1]
|
|
order.append(wl-1)
|
|
else:
|
|
order = list(range(wl))
|
|
|
|
random.shuffle(order)
|
|
|
|
for o in order:
|
|
# always 3 choices: right answer, wrong from correct set, random word
|
|
right = words[o]
|
|
|
|
choices = [right]
|
|
while 1:
|
|
n = words[random.randbelow(wl)]
|
|
if n in choices: continue
|
|
choices.append(n)
|
|
break
|
|
|
|
while 1:
|
|
n = bip39.wordlist_en[random.randbelow(0x800)]
|
|
if n in choices: continue
|
|
choices.append(n)
|
|
break
|
|
|
|
while 1:
|
|
random.shuffle(choices)
|
|
|
|
msg = '' if not dis.has_lcd else '\n'
|
|
|
|
msg += '\n'.join(' %d: %s' % (i+1, choices[i]) for i in range(3))
|
|
msg += '\n\nWhich word is right?\n\n%s to give up, %s to see all the words again.' % (X, OK)
|
|
|
|
ch = await ux_show_story(msg, title=title % (o+1), escape='123', sensitive=True)
|
|
if ch == 'x':
|
|
# user abort
|
|
return 'x'
|
|
elif ch == 'y':
|
|
await show_words(words)
|
|
continue
|
|
|
|
if ch in '123':
|
|
n = ord(ch) - ord('1')
|
|
|
|
if choices[n] == right:
|
|
break
|
|
|
|
await ux_dramatic_pause('Wrong!', 2)
|
|
|
|
return
|
|
|
|
async def make_seed_vault_menu(*a):
|
|
rv = SeedVaultMenu.construct()
|
|
return SeedVaultMenu(rv)
|
|
|
|
class SeedVaultMenu(MenuSystem):
|
|
|
|
@staticmethod
|
|
async def _set(menu, label, item):
|
|
from glob import dis
|
|
dis.fullscreen("Applying...")
|
|
|
|
encoded = item.arg # 72 bytes binary
|
|
|
|
await set_ephemeral_seed(encoded, is_restore=True)
|
|
|
|
goto_top_menu()
|
|
|
|
@staticmethod
|
|
async def _remove(menu, label, item):
|
|
from glob import dis, settings
|
|
|
|
esc = ""
|
|
tmp_val = False
|
|
idx, rec, encoded = item.arg
|
|
current_active = (pa.tmp_value == bytes(encoded))
|
|
|
|
msg = "Remove seed from seed vault"
|
|
if pa.tmp_value and current_active:
|
|
tmp_val = True
|
|
msg += "?\n\n"
|
|
else:
|
|
msg += (" and delete its settings?\n\n"
|
|
"Press %s to continue, press (1) to "
|
|
"only remove from seed vault and keep "
|
|
"encrypted settings for later use.\n\n") % OK
|
|
esc += "1"
|
|
|
|
msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere."
|
|
|
|
ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc)
|
|
if ch == "x": return
|
|
|
|
assert not_hobbled_mode()
|
|
|
|
dis.fullscreen("Saving...")
|
|
|
|
wipe_slot = not current_active and (ch != "1")
|
|
|
|
if wipe_slot:
|
|
xs = SettingsObject()
|
|
xs.set_key(encoded)
|
|
xs.load()
|
|
xs.blank()
|
|
del xs
|
|
|
|
|
|
# CAUTION: will get shadow copy if in tmp seed mode already
|
|
seeds = settings.master_get("seeds", [])
|
|
try:
|
|
del seeds[idx]
|
|
except IndexError:
|
|
pass
|
|
|
|
# need to load and work on master secrets, will be slow if on tmp seed
|
|
settings.master_set("seeds", seeds)
|
|
|
|
if tmp_val and wipe_slot:
|
|
goto_top_menu()
|
|
|
|
# pop menu stack
|
|
the_ux.pop()
|
|
m = the_ux.top_of_stack()
|
|
m.update_contents()
|
|
|
|
@staticmethod
|
|
async def _detail(menu, label, item):
|
|
rec, encoded = item.arg
|
|
|
|
# - first byte represents type of secret (internal encoding flags)
|
|
txt = SecretStash.summary(encoded[0])
|
|
|
|
detail = "Name:\n%s\n\nMaster XFP: %s\nSecret Type: %s\n\nOrigin:\n%s\n\n" \
|
|
% (rec.label, rec.xfp, txt, rec.origin)
|
|
|
|
await ux_show_story(detail)
|
|
|
|
@staticmethod
|
|
async def _rename(menu, label, item):
|
|
# let them edit the name
|
|
from glob import dis
|
|
from ux import ux_input_text
|
|
|
|
assert not_hobbled_mode()
|
|
|
|
idx, old = item.arg
|
|
new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40)
|
|
|
|
if not new_label:
|
|
return
|
|
|
|
dis.fullscreen("Saving...")
|
|
seeds = settings.master_get("seeds", [])
|
|
|
|
# save it
|
|
seeds[idx] = (old.xfp, old.encoded, new_label, old.origin)
|
|
# need to load and work on master secrets, will be slow if on tmp seed
|
|
settings.master_set("seeds", seeds)
|
|
|
|
# update label in sub-menu
|
|
menu.items[0].label = new_label
|
|
# take old arg, in rename we cannot change encoded value, so it can be used without
|
|
# the need to deserialize it again
|
|
_, encoded = menu.items[0].arg
|
|
menu.items[0].arg = VaultEntry(*seeds[idx]), encoded
|
|
|
|
# and name in parent menu too
|
|
parent = the_ux.parent_of(menu)
|
|
if parent:
|
|
parent.update_contents()
|
|
|
|
@staticmethod
|
|
async def _add_current_tmp(*a):
|
|
from pincodes import pa
|
|
|
|
assert not_hobbled_mode()
|
|
|
|
assert pa.tmp_value
|
|
main_xfp = settings.master_get("xfp", 0)
|
|
|
|
new_xfp = settings.get("xfp", 0)
|
|
new_xfp_str = xfp2str(new_xfp)
|
|
|
|
# do not offer to store main seed
|
|
if new_xfp == main_xfp:
|
|
return
|
|
|
|
xfp_ui = "[%s]" % new_xfp_str
|
|
|
|
ch = await ux_show_story(title=xfp_ui, msg="Add to Seed Vault?")
|
|
if ch != "y":
|
|
return
|
|
|
|
seeds = settings.master_get("seeds", [])
|
|
|
|
# Save it into master settings
|
|
seeds.append(list(VaultEntry(new_xfp_str,
|
|
SecretStash.storage_serialize(pa.tmp_value),
|
|
xfp_ui, "unknown origin")))
|
|
|
|
settings.master_set("seeds", seeds)
|
|
|
|
await ux_show_story(xfp_ui + "\nSaved to Seed Vault")
|
|
|
|
m = the_ux.top_of_stack()
|
|
m.update_contents()
|
|
|
|
@classmethod
|
|
def construct(cls):
|
|
# Dynamic menu with user-defined names of seeds shown
|
|
from pincodes import pa
|
|
|
|
rv = []
|
|
add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp)
|
|
|
|
seeds = list(seed_vault_iter())
|
|
|
|
if not seeds:
|
|
rv.append(MenuItem('(none saved yet)'))
|
|
if not_hobbled_mode():
|
|
if pa.tmp_value:
|
|
rv.append(add_current_tmp)
|
|
rv.append(MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu))
|
|
else:
|
|
wipe_if_deltamode()
|
|
|
|
tmp_in_sv = False
|
|
for i, rec in enumerate(seeds):
|
|
is_active = False
|
|
|
|
# de-serialize encoded secret
|
|
encoded = deserialize_secret(rec.encoded)
|
|
if encoded == pa.tmp_value:
|
|
is_active = tmp_in_sv = True
|
|
|
|
submenu = [
|
|
MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)),
|
|
MenuItem('Use This Seed', f=cls._set, arg=encoded),
|
|
MenuItem('Rename', f=cls._rename, arg=(i, rec),
|
|
predicate=not_hobbled_mode),
|
|
MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded),
|
|
predicate=not_hobbled_mode),
|
|
]
|
|
if is_active:
|
|
submenu[1] = MenuItem("Seed In Use")
|
|
submenu[1].is_chosen = lambda: True
|
|
|
|
if pa.tmp_value and (not is_active):
|
|
# if different ephemeral wallet active
|
|
# DO NOT offer any modification api (rename/delete)
|
|
submenu = submenu[:2]
|
|
|
|
item = MenuItem('%2d: %s' % (i+1, rec.label), menu=MenuSystem(submenu))
|
|
if is_active:
|
|
item.is_chosen = lambda: True
|
|
|
|
rv.append(item)
|
|
|
|
if pa.tmp_value:
|
|
if seeds and (not tmp_in_sv) and not_hobbled_mode():
|
|
# give em chance to store current active
|
|
rv.append(add_current_tmp)
|
|
|
|
from actions import restore_main_secret
|
|
rv.append(MenuItem("Restore Master", f=restore_main_secret))
|
|
|
|
return rv
|
|
|
|
def update_contents(self):
|
|
# Reconstruct the list of wallets on this dynamic menu, because
|
|
# we added or changed them and are showing that same menu again.
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
class SeedVaultChooserMenu(MenuSystem):
|
|
def __init__(self, words_only=False):
|
|
self.result = None
|
|
|
|
items = []
|
|
for i, rec in enumerate(seed_vault_iter()):
|
|
if words_only and not SecretStash.is_words(deserialize_secret(rec.encoded)):
|
|
continue
|
|
|
|
item = MenuItem('%2d: %s' % (i+1, rec.label), arg=rec, f=self.picked)
|
|
items.append(item)
|
|
|
|
if not items:
|
|
items.append(MenuItem("(none suitable)"))
|
|
|
|
super().__init__(items)
|
|
|
|
async def picked(self, menu, idx, mi):
|
|
assert menu == self
|
|
|
|
# show as "checked", for a touch
|
|
menu.chosen = idx
|
|
menu.show()
|
|
await sleep_ms(100)
|
|
|
|
self.result = mi.arg
|
|
the_ux.pop() # causes interact to stop
|
|
|
|
@classmethod
|
|
async def pick(cls, **kws):
|
|
# nice simple blocking menu present and pick
|
|
m = cls(**kws)
|
|
|
|
the_ux.push(m)
|
|
await m.interact()
|
|
|
|
return m.result
|
|
|
|
class EphemeralSeedMenu(MenuSystem):
|
|
|
|
@staticmethod
|
|
async def ephemeral_seed_import(menu, label, item):
|
|
return await ephemeral_seed_import(item.arg)
|
|
|
|
@staticmethod
|
|
async def ephemeral_seed_generate(menu, label, item):
|
|
return await ephemeral_seed_generate(item.arg)
|
|
|
|
@staticmethod
|
|
async def ephemeral_seed_generate_from_dice(menu, label, item):
|
|
return await ephemeral_seed_generate_from_dice(item.arg)
|
|
|
|
@classmethod
|
|
def construct(cls):
|
|
from glob import NFC
|
|
from actions import nfc_recv_ephemeral, import_xprv
|
|
from actions import restore_backup, scan_any_qr
|
|
from tapsigner import import_tapsigner_backup_file
|
|
from xor_seed import xor_restore_start
|
|
from charcodes import KEY_QR
|
|
|
|
import_ephemeral_menu = [
|
|
MenuItem("12 Words", f=cls.ephemeral_seed_import, arg=12),
|
|
MenuItem("18 Words", f=cls.ephemeral_seed_import, arg=18),
|
|
MenuItem("24 Words", f=cls.ephemeral_seed_import, arg=24),
|
|
MenuItem("Import via NFC", f=nfc_recv_ephemeral, predicate=bool(NFC)),
|
|
]
|
|
gen_ephemeral_menu = [
|
|
MenuItem("12 Words", f=cls.ephemeral_seed_generate, arg=12),
|
|
MenuItem("24 Words", f=cls.ephemeral_seed_generate, arg=24),
|
|
MenuItem("12 Word Dice Roll", f=cls.ephemeral_seed_generate_from_dice, arg=12),
|
|
MenuItem("24 Word Dice Roll", f=cls.ephemeral_seed_generate_from_dice, arg=24),
|
|
]
|
|
|
|
rv = [
|
|
MenuItem("Generate Words", menu=gen_ephemeral_menu, predicate=not_hobbled_mode),
|
|
MenuItem('Import from QR Scan', predicate=version.has_qr,
|
|
shortcut=KEY_QR, f=scan_any_qr, arg=(True, True)),
|
|
MenuItem("Import Words", menu=import_ephemeral_menu),
|
|
MenuItem("Import XPRV", f=import_xprv, arg=True), # ephemeral=True
|
|
MenuItem("Tapsigner Backup", f=import_tapsigner_backup_file, arg=True), # ephemeral=True
|
|
MenuItem("Coldcard Backup", f=restore_backup, arg=True), # tmp=True
|
|
MenuItem("Restore Seed XOR", f=xor_restore_start),
|
|
]
|
|
|
|
return rv
|
|
|
|
|
|
async def make_ephemeral_seed_menu(*a):
|
|
|
|
if (not pa.tmp_value) and (not settings.master_get("seedvault", False)):
|
|
# force a warning on them, unless they are already doing it.
|
|
if not await ux_confirm(
|
|
"Temporary seed is a secret completely separate "
|
|
"from the master seed, typically held in device RAM and "
|
|
"not persisted between reboots in the Secure Element. "
|
|
"Enable the Seed Vault feature to store these secrets longer-term.",
|
|
title="WARNING",
|
|
confirm_key="4"
|
|
):
|
|
return
|
|
|
|
rv = EphemeralSeedMenu.construct()
|
|
return EphemeralSeedMenu(rv)
|
|
|
|
async def start_b39_pw(menu, label, item):
|
|
# Menu item for top-level "Passphrase" item - take in a BIP-39 passphrase
|
|
|
|
if not settings.get('b39skip', False):
|
|
howto = '''\n\n\
|
|
On the next menu, you can enter a passphrase by selecting \
|
|
individual letters, choosing from the word list (recommended), \
|
|
or by typing numbers.'''
|
|
|
|
msg = '''\
|
|
You may add a passphrase to your BIP-39 seed words. \
|
|
This creates an entirely new wallet, for every possible passphrase.
|
|
|
|
By default, the Coldcard uses an empty string as the passphrase.\
|
|
%s\
|
|
|
|
Please write down the fingerprint of all your wallets, so you can \
|
|
confirm when you've got the right passphrase. (If you are writing down \
|
|
the passphrase as well, it's okay to put them together.) There is no way for \
|
|
the Coldcard to know if your entry is correct, and if you have it wrong, \
|
|
you will be looking at an empty wallet.
|
|
|
|
Limitations: 100 characters max length, ASCII characters 32-126 (0x20-0x7e) only.
|
|
|
|
%s to continue or press (2) to hide this message forever.
|
|
''' % (howto if not version.has_qwerty else '', OK)
|
|
|
|
ch = await ux_show_story(msg, escape='2')
|
|
if ch == '2':
|
|
settings.set('b39skip', True)
|
|
if ch == 'x':
|
|
return
|
|
|
|
if version.has_qwerty and not PassphraseSaver.has_file():
|
|
# no need for any menus if Q and no card present
|
|
pp = await ux_input_text('', prompt="Your BIP-39 Passphrase",
|
|
b39_complete=True, scan_ok=True, max_len=100)
|
|
if not pp: return
|
|
|
|
await apply_pass_value(pp)
|
|
else:
|
|
# provide a menu, especially on Mk4 where it offers a number of input methods
|
|
return PassphraseMenu()
|
|
|
|
|
|
class PassphraseMenu(MenuSystem):
|
|
# Collect up to 100 chars as a BIP-39 passphrase
|
|
|
|
# singleton (cls level) vars
|
|
done_cb = None
|
|
pp_sofar = ''
|
|
|
|
def __init__(self):
|
|
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),
|
|
]
|
|
else:
|
|
items = [
|
|
# xxxxxxxxxxxxxxxx
|
|
MenuItem('Edit Phrase', f=self.view_edit_phrase),
|
|
MenuItem('Add Word', menu=self.word_menu),
|
|
MenuItem('Add Numbers', f=self.add_numbers),
|
|
MenuItem('Clear All', f=self.empty_phrase),
|
|
MenuItem('APPLY', f=self.done_apply),
|
|
MenuItem('CANCEL', f=self.done_cancel),
|
|
]
|
|
|
|
# show Restore option only if required 'hidden' file is present (doesn't read it)
|
|
if PassphraseSaver.has_file():
|
|
items.insert(0, MenuItem('Restore Saved', menu=self.restore_saved))
|
|
|
|
return items
|
|
|
|
@staticmethod
|
|
async def restore_saved(*a):
|
|
dis.fullscreen("Decrypting...")
|
|
try:
|
|
items = PassphraseSaverMenu.construct()
|
|
except CardMissingError:
|
|
await needs_microsd()
|
|
return
|
|
except Exception as e:
|
|
await ux_show_story(title="Failure", msg=str(e) + problem_file_line(e))
|
|
return
|
|
|
|
if not items:
|
|
await ux_show_story("Nothing found")
|
|
return
|
|
|
|
return PassphraseSaverMenu(items)
|
|
|
|
async def on_cancel(self):
|
|
if not version.has_qwerty:
|
|
# zip to cancel item when they fail to exit via X button
|
|
self.goto_idx(self.count - 1)
|
|
else:
|
|
# for Q, just be a normal menu you can exit (but pop() didnt work here?)
|
|
the_ux.pop()
|
|
|
|
async def word_menu(self, *a):
|
|
# mk4: add a single word from the wordlist, maybe with space, various capitalizations
|
|
return SingleWordMenu()
|
|
|
|
@classmethod
|
|
async def add_numbers(cls, *a):
|
|
# Mk4 only: add some digits (quick, easy)
|
|
from ux_mk4 import ux_input_digits
|
|
|
|
pw = await ux_input_digits(cls.pp_sofar)
|
|
if pw is not None:
|
|
cls.pp_sofar = pw
|
|
cls.check_length()
|
|
|
|
@classmethod
|
|
async def empty_phrase(cls, *a):
|
|
if len(cls.pp_sofar) >= 3:
|
|
if not await ux_confirm("Press %s to clear passphrase." % OK):
|
|
return
|
|
|
|
cls.pp_sofar = ''
|
|
await ux_dramatic_pause('Cleared...', 0.25)
|
|
|
|
@classmethod
|
|
async def view_edit_phrase(cls, *a):
|
|
# let them control each character
|
|
pw = await ux_input_text(cls.pp_sofar, prompt="Your BIP-39 Passphrase",
|
|
b39_complete=True, scan_ok=True, max_len=100)
|
|
if pw is not None:
|
|
cls.pp_sofar = pw
|
|
cls.check_length()
|
|
|
|
if version.has_qwerty and cls.pp_sofar:
|
|
await apply_pass_value(cls.pp_sofar)
|
|
cls.pp_sofar = ''
|
|
|
|
@classmethod
|
|
def check_length(cls):
|
|
# enforce a limit of 100 chars
|
|
cls.pp_sofar = cls.pp_sofar[0:100]
|
|
|
|
@classmethod
|
|
async def add_text(cls, _1, _2, item):
|
|
cls.pp_sofar += item.label
|
|
cls.check_length()
|
|
|
|
while not isinstance(the_ux.top_of_stack(), PassphraseMenu):
|
|
the_ux.pop()
|
|
|
|
@classmethod
|
|
async def done_cancel(cls, *a):
|
|
if len(cls.pp_sofar) > 3:
|
|
if not await ux_confirm("What you have entered will be forgotten."):
|
|
return
|
|
|
|
cls.pp_sofar = ''
|
|
goto_top_menu()
|
|
|
|
@classmethod
|
|
async def done_apply(cls, *a):
|
|
# apply the passphrase
|
|
if not cls.pp_sofar:
|
|
# empty string here - noop
|
|
return
|
|
|
|
await apply_pass_value(cls.pp_sofar)
|
|
cls.pp_sofar = ''
|
|
|
|
async def apply_pass_value(new_pp):
|
|
# Apply provided BIP-39 passphrase to master or current active tmp seed
|
|
# and go to top menu.
|
|
nv, xfp, parent_xfp = await calc_bip39_passphrase(new_pp)
|
|
xfp_str = xfp2str(xfp)
|
|
parent_xfp_str = xfp2str(parent_xfp)
|
|
|
|
msg = "current active temporary seed [%s]" if pa.tmp_value else "master seed [%s]"
|
|
msg = msg % parent_xfp_str
|
|
|
|
msg = ('Above is the master key fingerprint of the new wallet'
|
|
' created by adding passphrase to %s.'
|
|
'\n\nPassphrase: %s'
|
|
'\n\nPress %s to abort, %s to use the new wallet, (1) to apply'
|
|
' and save to MicroSD for future.') % (msg, new_pp, X, OK)
|
|
|
|
ch = await ux_show_story(msg, title="[%s]" % xfp_str, escape='1')
|
|
if ch == 'x':
|
|
return
|
|
|
|
await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=new_pp,
|
|
origin="BIP-39 Passphrase on [%s]" % parent_xfp_str)
|
|
|
|
if ch == '1':
|
|
try:
|
|
await PassphraseSaver().append(xfp, new_pp)
|
|
except CardMissingError:
|
|
await needs_microsd()
|
|
except Exception as e:
|
|
await ux_show_story(
|
|
title="ERROR",
|
|
msg='Save failed!\n\n%s\n%s' % (e, problem_file_line(e))
|
|
)
|
|
|
|
goto_top_menu()
|
|
|
|
class SingleWordMenu(WordNestMenu):
|
|
# NOTE: not used on Q1
|
|
def __init__(self, items=None, **kws):
|
|
if items:
|
|
super(SingleWordMenu, self).__init__(items=items, **kws)
|
|
else:
|
|
super(SingleWordMenu, self).__init__(num_words=1, has_checksum=False,
|
|
done_cb=self.commit_value)
|
|
|
|
@staticmethod
|
|
async def commit_value(new_words):
|
|
# create one more menu w/ the word and some variations on that word
|
|
word = new_words[0]
|
|
options = [word, word[0].upper() + word[1:], word.upper()]
|
|
for w in options[:]:
|
|
options.append(' ' + w)
|
|
|
|
# bugfix: in case they cancel from new menu
|
|
WordNestMenu.words = []
|
|
|
|
return MenuSystem([MenuItem(w, f=PassphraseMenu.add_text)
|
|
for n,w in enumerate(options)], space_indicators=True)
|
|
|
|
|
|
# EOF
|