seed entry, dice rolling

This commit is contained in:
Peter D. Gray 2023-08-07 11:58:00 -04:00 committed by scgbckbone
parent 4b7d331bef
commit 02b004b3cd
7 changed files with 318 additions and 136 deletions

View File

@ -21,7 +21,7 @@ from glob import settings
from pincodes import pa
from menu import start_chooser
from version import MAX_TXN_LEN
from charcodes import KEY_NFC
from charcodes import KEY_NFC, KEY_QR
CLEAR_PIN = '999999-999999'
@ -516,7 +516,7 @@ async def start_seed_import(menu, label, item):
import seed
if version.has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry('Enter Seed Words', num_words,
await seed_word_entry('Enter Seed Words', item.arg,
done_cb=seed.commit_new_words)
else:
return seed.WordNestMenu(item.arg)
@ -607,6 +607,7 @@ consequences.''', escape='4')
def render_master_secrets(mode, raw, node):
# Render list of words, or XPRV / master secret to text.
import stash, chains
from ux import ux_render_words
c = chains.current_chain()
qr_alnum = False
@ -621,7 +622,7 @@ def render_master_secrets(mode, raw, node):
qr_alnum = True
msg = 'Seed words (%d):\n' % len(words)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))
msg += ux_render_words(words)
if stash.bip39_passphrase:
msg += '\n\nBIP-39 Passphrase:\n *****'
@ -683,8 +684,8 @@ async def view_seed_words(*a):
msg += '\n\nPress (1) to view as QR Code.'
while 1:
ch = await ux_show_story(msg, sensitive=True, escape='1')
if ch == '1':
ch = await ux_show_story(msg, sensitive=True, escape='1'+KEY_QR)
if ch in '1'+KEY_QR:
from ux import show_qr_code
await show_qr_code(qr, qr_alnum)
continue

View File

@ -37,10 +37,12 @@ COL_WHITE = 0xffff
COL_BLACK = 0x0000
COL_PROGRESS = COL_TEXT
# Just one bit for attribute data (for now)
FLAG_INVERT = 0x8000
ATTR_MASK = 0x8000
# text display attributes, ie. colours
# XXX not implem
AT_INVERT = 0x1
AT_GREY25 = 0x1
AT_GREY50 = 0x1
@ -192,14 +194,6 @@ class Display:
self.dis.show_zpixels(x, y, w, h, data)
self.mark_correct(x, y, w, h)
def mark_lines_dirty(self, rng):
# mark a bunch of lines as needing redraw
# - for QR which covers most of screen
# - DELME
for y in rng:
self.last_buf[y] = array.array('H', (0xfffe for i in range(CHARS_W)))
self.next_buf[y] = array.array('H', (0xfffe for i in range(CHARS_W)))
def mark_correct(self, px, py, w, h):
# mark a subset of the screen as already drawn correctly
# - because we drew an image in that spot already (immediate)
@ -268,7 +262,7 @@ class Display:
self.next_buf[y][x] = 0
x += 1
return end_x
return end_x if end_x is not None else x
def real_clear(self, _internal=False):
# fill to black, but only text area, not status bar
@ -345,16 +339,24 @@ class Display:
assert 0 <= cursor.y < CHARS_H, 'cur y'
self.gpu.cursor_at(*cursor)
self.last_buf[cursor.y][cursor.x] = 0xfffd
if cursor.dbl_wide:
if cursor.dbl_wide and cursor.x < CHARS_W-1:
self.last_buf[cursor.y][cursor.x+1] = 0xfffd
# rather than clearing and redrawing, use this buffer w/ fixed parts of screen
# - obsolete concept
# When drawing another screen for a bit, then coming back, use these
def save_state(self):
# TODO: should be a dataclass w/ all our state details
return ([array.array('H', ln) for ln in self.last_buf], self.last_prog_x)
def restore_state(self, old_state):
rows, self.next_prog_x = old_state
for y in range(CHARS_H):
self.next_buf[y][:] = rows[y]
self.show()
# obsolete OLED approach
def save(self):
pass
raise NotImplementedError
def restore(self):
pass
def clear_rect(self, x,y, w,h):
raise NotImplementedError
def hline(self, y):
@ -367,9 +369,14 @@ class Display:
#self.dis.fill_rect(x,TOP_MARGIN, 1, ACTIVE_H, 0xffff)
pass
def clear_rect(self, x,y, w,h):
# but see clear_box() instead
raise NotImplementedError
def scroll_bar(self, fraction):
# along right edge
# MAYBE TODO: make this internal, part of show and make fraction a var?
# XXX not showing at all
self.gpu.take_spi()
self.dis.fill_rect(WIDTH-5, 0, 5, HEIGHT, 0)
mm = HEIGHT-6
@ -464,23 +471,7 @@ class Display:
self.text(x, ry, ' '+msg+' ', invert=is_sel)
if is_checked:
#self.text(CHARS_W-3, ry, '✔︎')
self.text(len(msg)+2, ry, '✔︎')
if 0:
if is_sel:
#ln = '▶ %s ◀' % msg
#ln = '█▌%s▐█' % msg
ln = '█▌%-29s▐█' % msg
else:
ln = ' ' + msg
if is_checked:
ln = '%-34s' % ln
ln = ln[:CHARS_W-3] + ''
self.text(0, ry, ln)
self.text(len(msg)+2, ry, '')
def show_yikes(self, lines):
# dump a stack trace

View File

@ -24,7 +24,7 @@ from glob import settings, dis
from pincodes import pa
from nvstore import SettingsObject
from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR
from charcodes import KEY_QR, KEY_SELECT, KEY_CANCEL
# seed words lengths we support: 24=>256 bits, and recommended
@ -273,7 +273,10 @@ individual words if you wish.''')
async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False):
msg = (prompt or 'Record these %d secret words!\n') % len(words)
msg += '\n'.join('%2d: %s' % (i, w) for i, w in enumerate(words, start=1))
from ux import ux_render_words
msg += ux_render_words(words)
msg += '\n\nPlease check and double check your notes.'
if not ephemeral:
# user can skip quiz for ephemeral secrets
@ -301,9 +304,7 @@ async def show_words(words, prompt=None, escape=None, extra='', ephemeral=False)
async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
from glob import dis
# XXX q1 support
from display import FontTiny, FontLarge
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"
@ -324,23 +325,13 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
md = sha256(seed)
pr = PressRelease()
# fixed parts of screen
dis.clear()
y = 38
dis.text(0, y, "Press 1-6 for each dice"); y += 13
dis.text(0, y, "roll to mix in.")
dis.save()
# draws initial screen, and returns funct to update count and/or hash
screen_updater = ux_dice_rolling()
while 1:
# Note: cannot scroll this msg because 5=up arrow
dis.restore()
dis.text(None, 0, '%d rolls' % count, FontLarge)
hx = str(b2a_hex(md.digest()), 'ascii')
dis.text(0, 20, hx[0:32], FontTiny)
dis.text(0, 20+7, hx[32:], FontTiny)
dis.show()
screen_updater(count, hx)
ch = await pr.wait()
@ -348,19 +339,18 @@ async def add_dice_rolls(count, seed, judge_them, nwords=None, enforce=False):
count += 1
counter[ch] = counter.get(ch, 0) + 1 # mimics defaultdict
dis.restore()
dis.text(None, 0, '%d rolls' % count, FontLarge)
dis.show()
# show udpated count immediately
screen_updater(count, None)
# this is slow enough to see
md.update(ch)
elif ch == 'x':
elif ch == KEY_CANCEL:
# 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 == 'y':
elif ch == KEY_SELECT:
if count < threshold and judge_them:
if not count:
return 0, seed
@ -777,8 +767,10 @@ async def word_quiz(words, limited=None, title='Word %d is?'):
while 1:
random.shuffle(choices)
msg = '\n'.join(' %d: %s' % (i+1, choices[i]) for i in range(3))
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\nX to give up, OK to see all the words again.'
ch = await ux_show_story(msg, title=title % (o+1), escape='123', sensitive=True)

View File

@ -18,14 +18,14 @@ if version.has_qwerty:
CH_PER_W = CHARS_W
STORY_H = CHARS_H
from ux_q1 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
from ux_q1 import ux_login_countdown
from ux_q1 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
else:
# How many characters can we fit on each line? How many lines?
# (using FontSmall)
CH_PER_W = 17
STORY_H = 5
from ux_mk4 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin
from ux_mk4 import ux_login_countdown
from ux_mk4 import ux_login_countdown, ux_confirm, ux_dice_rolling, ux_render_words
class UserInteraction:
def __init__(self):
@ -237,13 +237,6 @@ async def idle_logout():
from actions import logout_now
await logout_now()
return # not reached
async def ux_confirm(msg):
# confirmation screen, with stock title and Y=of course.
resp = await ux_show_story("Are you SURE ?!?\n\n" + msg)
return resp == 'y'
async def ux_dramatic_pause(msg, seconds):

View File

@ -60,6 +60,14 @@ class PressRelease:
else:
self.last_key = ch
return ch
async def ux_confirm(msg):
# confirmation screen, with stock title and Y=of course.
from ux import ux_show_story
resp = await ux_show_story("Are you SURE ?!?\n\n" + msg)
return resp == 'y'
async def ux_enter_number(prompt, max_value, can_cancel=False):
# return the decimal number which the user has entered
@ -462,4 +470,32 @@ async def ux_login_countdown(sec):
dis.busy_bar(0)
def ux_dice_rolling():
from glob import dis
from display import FontTiny, FontLarge
# draw fixed parts of screen
dis.clear()
y = 38
dis.text(0, y, "Press 1-6 for each dice"); y += 13
dis.text(0, y, "roll to mix in.")
dis.save()
def update(count, hx=None):
dis.restore()
dis.text(None, 0, '%d rolls' % count, FontLarge)
if hx is not None:
dis.text(0, 20, hx[0:32], FontTiny)
dis.text(0, 20+7, hx[32:], FontTiny)
dis.show()
# return funct to draw updating part
return update
def ux_render_words(words):
# caution: text layout here, and flag sensitive=T trigger side-channel defenses
return '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))
# EOF

View File

@ -5,7 +5,7 @@
from uasyncio import sleep_ms
import utime, gc
from charcodes import *
from lcd_display import CHARS_W, CursorSpec
from lcd_display import CHARS_W, CHARS_H, CursorSpec
from exceptions import AbortInteraction
import bip39
@ -61,6 +61,14 @@ class PressRelease:
else:
self.last_key = ch
return ch
async def ux_confirm(msg):
# confirmation screen, with stock title and Y=of course.
from ux import ux_show_story
resp = await ux_show_story('\n' + msg, title="Are you SURE ?!?")
return resp == 'y'
async def ux_enter_number(prompt, max_value, can_cancel=False):
# return the decimal number which the user has entered
@ -82,7 +90,7 @@ async def ux_enter_number(prompt, max_value, can_cancel=False):
while 1:
# TODO: check width, go to two lines if needed? depends on prompt text
bx = dis.text(2, 4, prompt + ' ' + value)
dis.show(cursor=CursorSpec(bx, 4))
dis.show(cursor=CursorSpec(bx, 4, 0, 0))
ch = await press.wait()
if ch == KEY_SELECT:
@ -117,27 +125,20 @@ async def ux_input_numbers(val, validate_func):
pass
async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False, num_words=0):
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=True):
# Get a text string.
# - Should allow full unicode, NKDN
# - but our font is mostly just ascii
# - no control chars allowed either
# - TODO: press QR -> do scan and use that text
# - TODO: regex validation for derviation paths
# - TODO: assume phrase seed entry
# - TODO: regex validation for derviation paths?
from glob import dis
from ux import ux_show_story
dis.clear()
if num_words:
b39_complete = True
max_len = 216
min_len = 36
got_words = []
if b39_complete:
dis.text(None, -2, "↦ to auto-complete. (QR) to scan.")
dis.text(None, -1, "CANCEL or SELECT when done.")
# TODO:
@ -150,8 +151,6 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
ch_remap = lambda ch: ch if ' ' <= ch < chr(127) else None
if hex_only:
ch_remap = lambda ch: ch.lower() if ch in '0123456789abcdefABCDEF' else None
if num_words:
ch_remap = lambda ch: ch.lower() if ch==' ' or ch.isalpha() else None
y = 2
@ -211,11 +210,7 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
ch = await press.wait()
if ch == KEY_SELECT:
if num_words:
if len(got_words) == num_words:
break
err_msg = 'Need exactly %d words.' % num_words
elif len(value) >= min_len:
if len(value) >= min_len:
break
else:
err_msg = 'Need %d characters at least.' % min_len
@ -254,7 +249,6 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
pref = ''.join(pref)
exact, nextchars, is_word = bip39.next_char(pref.lower())
print('%s => exact=%s nextchars=%s is_word=%s' % (pref, exact, nextchars, is_word))
if not is_word and len(nextchars) == 1:
# only one possible next char, so complete for them
@ -273,31 +267,15 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
is_word = is_word.upper()
value += is_word[len(pref):]
if num_words:
got_words.append(exact)
if len(got_words) < num_words:
value += ' '
elif not nextchars:
err_msg = 'Not a BIP-39 word: ' + pref
elif len(nextchars) < 12:
elif len(nextchars) < 18:
# 'sta' and other s-prefixes
err_msg = 'Press next key: ' + nextchars
else:
err_msg = 'Need more letters.'
elif num_words and ch == ' ':
# doing a space during seed-word entry: must be forming a
# valid word, or fail
last = value.split(' ')[-1]
if last:
exact, nextchars, is_word = bip39.next_char(last)
if exact and not is_word:
is_word = last # "act " case
if is_word:
got_words.append(is_word)
value += ' '
else:
err_msg = 'Not a BIP-39 word: ' + last
else:
ch = ch_remap(ch)
if ch is not None:
@ -306,21 +284,6 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
else:
value = value[0:max_len-1] + ch
if num_words and ch != ' ':
last = value.split(' ')[-1]
if len(last) >= 4:
exact, nextchars, is_word = bip39.next_char(last)
if not exact:
err_msg = 'Not a BIP-39 prefix: ' + last
else:
got_words.append(is_word)
value = value + is_word[len(last):] + ' '
if num_words:
return got_words
return value
@ -409,16 +372,216 @@ async def ux_login_countdown(sec):
dis.busy_bar(0)
def ux_render_words(words):
# re-use word-list rendering code to show as a string in a story.
# - because I want them all on-screen at once, and not simple to do that
buf = [bytearray(CHARS_W) for y in range(CHARS_H)]
rv = ['']
num_words = len(words)
if num_words == 12:
for y in range(6):
rv.append('%2d: %-8s %2d: %s' % (y+1, words[y], y+7, words[y+6]))
else:
lines = 6 if num_words == 18 else 8
for y in range(lines):
rv.append('%d:%-8s %2d:%-8s %2d:%s' % (y+1, words[y],
y+lines+1, words[y+lines],
y+(lines*2)+1, words[y+(lines*2)]))
return '\n'.join(rv)
def ux_draw_words(y, num_words, words):
# Draw seed words on single screen (hard) and return x/y position of start of each
from glob import dis
if num_words == 12:
cols = 2
xpos = [2, 18]
else:
cols = 3
xpos = [0, 11, 23]
n_per_c = num_words // cols # 6/4/8
rv = []
for n, word in enumerate(words, 1):
if num_words == 12:
# luxious space after colon
msg = ('%2d: ' % n) + word
x_off = 3
else:
if n <= n_per_c:
# no space in front of 1: thru N: in leftmost column of 3
msg = ('%d:' % n) + word
x_off = 2
else:
msg = ('%2d:' % n) + word
x_off = 3
X, Y = xpos[(n-1) // n_per_c], y + ((n-1) % n_per_c)
dis.text(X, Y, msg)
rv.append( (X+x_off, Y) )
return rv
async def seed_word_entry(prompt, num_words, has_checksum=True, done_cb=None):
# Accept a seed phrase
# - replaces WordNestMenu()'s constructor
# - return a function that will be called w/ menu details (dont care)
# Accept a seed phrase, only
# - replaces WordNestMenu on Q1
# - max word length is 8, min is 3
# - useful: simulator.py --q1 --eff --seq 'aa ee 4i '
from glob import dis
assert num_words and prompt and done_cb
v = await ux_input_text('', confirm_exit=False, prompt=prompt, num_words=num_words)
if not v:
return
words = ['' for i in range(num_words)]
await done_cb(v)
dis.clear()
dis.text(None, 0, prompt, invert=1)
pos = ux_draw_words(2 if num_words != 24 else 1, num_words, words)
word_num = 0
value = ''
err_msg = last_err = None
press = PressRelease()
last_words = []
while 1:
if word_num == num_words:
# useful to show final word on screen, even tho confirm not needed
err_msg = 'Press SELECT if all done.' if not has_checksum else \
'Valid words! Press SELECT.'
cur = None
else:
x, y = pos[word_num]
ln = len(value)
if ln == 8:
# outline mode if on final possible location
cur = CursorSpec(x+ln-1, y, 0, True)
else:
cur = CursorSpec(x+ln, y, 0, 0)
dis.text(x, y, '%-8s' % value)
# show error msg, until they type anything to clear it
if err_msg:
dis.text(None, -1, err_msg)
err_msg = None
last_err = True
elif last_err:
dis.text(None, -1, '')
last_err = False
dis.show(cursor=cur)
ch = await press.wait()
commit = False
if ch == KEY_SELECT:
if word_num == num_words:
break
commit = True
elif ch == KEY_DELETE or ch == KEY_LEFT:
# delete last char
if len(value) > 0:
value = value[:-1]
elif word_num:
# go to prev word
word_num -= 1
words[word_num] = value = ''
elif ch == KEY_CLEAR:
value = ''
elif ch == KEY_CANCEL:
if word_num >= 2:
tmp = dis.save_state()
ok = await ux_confirm("Everything you've entered will be lost.")
if not ok:
dis.restore_state(tmp)
continue
return None
elif ch in { ' ', KEY_TAB, KEY_DOWN, KEY_RIGHT }:
# re-consider if word done, like "act" and other 3-letter cases
commit = True
elif ch.isalpha():
value += ch.lower()
else:
continue
if has_checksum and word_num == num_words-1 and (len(value) >= 1 or commit):
assert last_words
if value not in last_words:
maybe = [i for i in last_words if i.startswith(value)]
if len(maybe) == 1:
value = maybe[0]
elif len(maybe) == 0:
if len(last_words) == 8: # 24 words case
ll = ''.join(sorted(set([w[0] for w in last_words])))
err_msg = 'Final word starts with: ' + ll
else:
##################################
err_msg = "Final word cannot start with: " + value
value = value[:-1]
continue
else:
nextchars = ''.join(sorted(set(i[len(value)] for i in maybe)))
err_msg = 'Next key: ' + nextchars
continue
if value in last_words:
dis.text(x, y, '%-8s' % value)
words[word_num] = value
word_num += 1
value = ''
continue
if len(value) >= 2:
exact, nextchars, is_word = bip39.next_char(value)
#print('%s => exact=%s nextchars=%s is_word=%s' % (value, exact, nextchars, is_word))
if exact and not is_word and commit:
# they pressed space after a valid 3 letter prefix (act vs actor)
is_word = value
if is_word:
# word is from list, so we are done... move to next word
words[word_num] = is_word
dis.text(x, y, '%-8s' % is_word)
word_num += 1
value = ''
if has_checksum and word_num == num_words-1:
# calc all possible final words
# 12 -> 128, 18->32, 24->8
last_words = list(bip39.a2b_words_guess(words[:-1]))
elif not nextchars:
err_msg = 'Not a BIP-39 word: ' + value
value = value[0:3]
else:
# 'sta' and other s-prefixes can have many choices!
err_msg = 'Next key: ' + nextchars
await done_cb(words)
def ux_dice_rolling():
from glob import dis
# draw fixed parts of screen
dis.clear()
dis.text(0, 1, "Press 1-6 for each dice roll")
dis.text(0, 2, "to mix in.")
def update(count, hx=None):
dis.text(None, 4, '%d rolls so far' % count, invert=1)
if hx is not None:
dis.text(0, -2, hx[0:32]+'-')
dis.text(2, -1, ''+hx[32:])
dis.show()
# return funct to draw updating part
return update
# EOF

View File

@ -6,11 +6,12 @@
# - all combination of partial XOR seed phrases are working wallets
#
import stash, ngu, bip39, random
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_render_words
from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed
from glob import settings
from actions import goto_top_menu
from version import has_qwerty
from charcodes import KEY_CANCEL
def xor(*args):
@ -104,7 +105,7 @@ Otherwise, press OK to continue.'''.format(n=num_parts), escape='2')
while 1:
ch = await show_n_parts(word_parts, chk_word)
if ch == 'x':
if ch == KEY_CANCEL:
if not use_rng: return
if await ux_confirm("Stop and forget those words?"):
return
@ -112,7 +113,7 @@ Otherwise, press OK to continue.'''.format(n=num_parts), escape='2')
for ws, part in enumerate(word_parts):
ch = await word_quiz(part, title='Word %s%%d is?' % chr(65+ws))
if ch == 'x': break
if ch == KEY_CANCEL: break
else:
break
@ -158,8 +159,13 @@ async def xor_all_done(new_words):
elif ch == '1':
# do another list of words
nxt = XORWordNestMenu(num_words=target_words)
the_ux.push(nxt)
if has_qwerty:
from ux_q1 import seed_word_entry
await seed_word_entry("Part %s Words" % chr(65+len(import_xor_parts)),
target_words, done_cb=xor_all_done)
else:
nxt = XORWordNestMenu(num_words=target_words)
the_ux.push(nxt)
elif ch == '2':
# done; import on temp basis, or be the main secret
@ -204,7 +210,7 @@ async def show_n_parts(parts, chk_word):
for n,words in enumerate(parts):
msg += 'Part %s:\n' % chr(65+n)
msg += '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words))
msg += ux_render_words(words)
msg += '\n\n'
msg += ('The correctly reconstructed seed phrase will have this final word,'