From 48670a8ef70957789618cd0673260d817db7e1a4 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 28 Mar 2019 11:35:35 -0400 Subject: [PATCH] Basics of passphrase data entry --- graphics/graphics.py | 6 + graphics/sm_box.txt | 17 +++ graphics/space.txt | 2 + graphics/spin.txt | 36 +++++ shared/actions.py | 4 + shared/flow.py | 4 +- shared/menu.py | 3 + shared/seed.py | 310 +++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 graphics/sm_box.txt create mode 100644 graphics/space.txt create mode 100644 graphics/spin.txt diff --git a/graphics/graphics.py b/graphics/graphics.py index edf2497b..dfdf9e9f 100644 --- a/graphics/graphics.py +++ b/graphics/graphics.py @@ -13,6 +13,12 @@ class Graphics: selected = (15, 12, 2, 0, b'\x00\x00\x00\x00\x00\x06\x00\x0c\x00\x18\x0000`\x18\xc0\r\x80\x07\x00\x02\x00\x00\x00') + sm_box = (11, 17, 2, 0, b'\xe4\xe0\x80 \x80 \x80 \x00\x00\x00\x00\x80 \x00\x00\x00\x00\x00\x00\x80 \x00\x00\x00\x00\x80 \x80 \x80 \xe4\xe0') + + space = (9, 2, 2, 0, b'\x80\x80\xff\x80') + + spin = (13, 36, 2, 0, b'\x02\x00\x07\x00\x0f\x80\x1f\xc0\x00\x00\x00\x00\x00\x00\xf2x\x80\x08\x80\x08\x80\x08\x00\x00\x00\x00\x80\x08\x00\x00\x00\x00\x00\x00\x80\x08\x00\x00\x00\x00\x00\x00\x80\x08\x00\x00\x00\x00\x80\x08\x80\x08\x80\x08\xf2x\x00\x00\x00\x00\x00\x00\x1f\xc0\x0f\x80\x07\x00\x02\x00\x00\x00') + wedge = (6, 11, 1, 0, b'\x00\x00\xc0\xe0p8\x1c8p\xe0\xc0') xbox = (13, 21, 2, 0, b'\xff\xf8\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\x88\x88\xa2(\xff\xf8') diff --git a/graphics/sm_box.txt b/graphics/sm_box.txt new file mode 100644 index 00000000..762dafd4 --- /dev/null +++ b/graphics/sm_box.txt @@ -0,0 +1,17 @@ +xxx x xxx +x x +x x +x x + + +x x + + + +x x + + +x x +x x +x x +xxx x xxx diff --git a/graphics/space.txt b/graphics/space.txt new file mode 100644 index 00000000..cddef349 --- /dev/null +++ b/graphics/space.txt @@ -0,0 +1,2 @@ +x x +xxxxxxxxx diff --git a/graphics/spin.txt b/graphics/spin.txt new file mode 100644 index 00000000..1ccf10d7 --- /dev/null +++ b/graphics/spin.txt @@ -0,0 +1,36 @@ + x + xxx + xxxxx + xxxxxxx + + + +xxxx x xxxx +x x +x x +x x + + +x x + + + +x x + + + +x x + + +x x +x x +x x +xxxx x xxxx + + + + xxxxxxx + xxxxx + xxx + x + diff --git a/shared/actions.py b/shared/actions.py index 2bb032ac..2ce9d12d 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -371,6 +371,10 @@ async def start_seed_import(menu, label, item): import seed return seed.WordNestMenu(item.arg) +async def start_b39_pw(menu, label, item): + import seed + return seed.PassphraseMenu() + def pick_new_wallet(*a): import seed return seed.make_new_wallet() diff --git a/shared/flow.py b/shared/flow.py index 1b2e7bf2..8257c376 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -153,12 +153,11 @@ EmptyWallet = [ ] - # In operation, normal system, after a good PIN received. NormalSystem = [ # xxxxxxxxxxxxxxxx MenuItem('Ready To Sign', f=ready2sign), - MenuItem('Passphrase BIP39', f=set_bip39_phrase), + MenuItem('Passphrase BIP39', f=start_b39_pw), MenuItem('Secure Logout', f=logout_now), MenuItem('Advanced', menu=AdvancedNormalMenu), MenuItem('Settings', menu=SettingsMenu), @@ -174,4 +173,3 @@ FactoryMenu = [ MenuItem("Debug Functions", menu=DebugFunctionsMenu), MenuItem("Perform Selftest", f=start_selftest), ] - diff --git a/shared/menu.py b/shared/menu.py index 61ad6563..e8a394c0 100644 --- a/shared/menu.py +++ b/shared/menu.py @@ -102,6 +102,9 @@ class MenuSystem: else: dis.text(x, y, msg) + if msg[0] == ' ': + dis.icon(x-2, y+11, 'space', invert=is_sel) + if self.chosen is not None and (n+self.ypos) == self.chosen: dis.icon(108, y, 'selected', invert=is_sel) diff --git a/shared/seed.py b/shared/seed.py index 83b1aec9..20e52600 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -14,7 +14,7 @@ from menu import MenuItem, MenuSystem from utils import pop_count import tcc, uctypes -from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm +from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, ux_wait_keyup from pincodes import AE_SECRET_LEN from actions import goto_top_menu from stash import SecretStash, SensitiveValues @@ -145,11 +145,12 @@ class WordNestMenu(MenuSystem): 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 WordNestMenu(items=[MenuItem(i, menu=self.next_menu) for i in ch]) + return cls(items=[MenuItem(i, menu=self.next_menu) for i in ch]) # terminal choice, start next word words.append(choice.label) @@ -167,19 +168,18 @@ class WordNestMenu(MenuSystem): # they have checksum right, so they are certainly done. if correct: # they are done, don't force them to do any more! - await WordNestMenu.done_cb(words.copy()) - return None + return await cls.done_cb(words.copy()) else: # give them a chance to confirm and/or start over - return WordNestMenu(is_commit=True, items = [ + return cls(is_commit=True, items = [ MenuItem('(INCORRECT)', f=self.explain_error), MenuItem('(start over)', f=self.start_over)]) # pop stack to reset depth, and start again at a- .. z- - WordNestMenu.pop_all() + cls.pop_all() - return WordNestMenu(items=None, is_commit=True) + return cls(items=None, is_commit=True) @classmethod def pop_all(cls): @@ -214,6 +214,8 @@ class WordNestMenu(MenuSystem): # clear menu stack goto_top_menu() + return None + async def explain_error(self, *a): await ux_show_story('''\ @@ -224,11 +226,11 @@ individual words if you wish.''') async def start_over(self, *a): # pop everything we've done off the stack - WordNestMenu.pop_all() + self.pop_all() # begin again, empty but same settings - WordNestMenu.words = [] - the_ux.push(WordNestMenu(items=None)) + self.words = [] + the_ux.push(self.__class__(items=None)) def late_draw(self, dis): # add an overlay with "word N" in small text, top right. @@ -491,4 +493,292 @@ async def word_quiz(words, limited=None): return +pp_sofar = '' + +class PassphraseMenu(MenuSystem): + # Collect up to 100 chars as a BIP39 passphrase + + # singleton (cls level) vars + done_cb = None + + def __init__(self, done_cb=None, items=None): + global pp_sofar + pp_sofar = '' + + items = [ + # xxxxxxxxxxxxxxxx + MenuItem('Edit Phrase', f=self.view_edit_phrase), + MenuItem('Add Word', menu=self.word_menu), + #MenuItem('+Space', f=self.add_space), + MenuItem('Add Numbers', f=self.add_numbers), + #MenuItem('+Letter'), + #MenuItem('+Symbol'), + #MenuItem('-Backspace', f=self.backspace), + MenuItem('Clear All', f=self.empty_phrase), + MenuItem('SAVE', f=self.done_done), + MenuItem('CANCEL', f=self.done_done), + ] + + super(PassphraseMenu, self).__init__(items) + + + @classmethod + def pop_all(cls): + while isinstance(the_ux.top_of_stack(), cls): + the_ux.pop() + + def late_draw(self, dis): + # add an overlay with "word N" in small text, top right. + + invert = (self.cursor == self.ypos) + self.draw_chars(dis, invert) + + @classmethod + def draw_chars(cls, dis, invert=False, plus=0): + # note: imperfect when showing 100 chars, and that's ok. + from display import FontTiny + count = len(pp_sofar) + plus + y = 6 + dis.text(-8, y-4, "%d" % count, invert=invert) + dis.text(-18-(6 if count >= 10 else 0), y, "Chars", FontTiny, invert=invert) + + async def word_menu(self, *a): + return SingleWordMenu() + + async def add_space(self, *a): + global pp_sofar + pp_sofar += ' ' + self.check_length() + + async def add_numbers(self, *a): + # collect a series of digits + from main import dis + from display import FontTiny, FontSmall + global pp_sofar + + footer = "X to DELETE, or OK when DONE." + lx = 6 + y = 16 + here = '' + while 1: + dis.clear() + + # text centered + msg = here + by = y + bx = dis.text(lx, y, msg[0:16]) + dis.text(lx, y-8, pp_sofar, FontTiny) + + if len(msg) > 16: + # second line when needed (left just) + by += 15 + bx = dis.text(lx, by, msg[16:]) + + if len(here) < 32: + dis.icon(bx, by-2, 'sm_box') + + dis.text(None, -1, footer, FontTiny) + dis.show() + + ch = await ux_wait_keyup('0123456789xy') + if ch == 'y': + pp_sofar += here + self.check_length() + return + elif ch == 'x': + if here: + here = here[0:-1] + else: + # quit if they press X on empty screen + return + else: + if len(here) < 32: + here += ch + + async def empty_phrase(self, *a): + global pp_sofar + if not pp_sofar or len(pp_sofar) < 3: + pp_sofar = '' + else: + if await ux_confirm("Press OK to clear passphrase. X to cancel."): + pp_sofar = '' + + async def backspace(self, *a): + global pp_sofar + if pp_sofar: + pp_sofar = pp_sofar[0:-1] + + async def view_phrase(self, *a): + await ux_show_story('\n%s\n\n' % (pp_sofar or '-> EMPTY <-'), title='Passphrase') + + async def view_edit_phrase(self, *a): + # let them control each character + global pp_sofar + pw = await spinner_edit(pp_sofar) + if pw is not None: + pp_sofar = pw + self.check_length() + + @classmethod + def check_length(cls): + # enforce a limit of 100 chars + global pp_sofar + pp_sofar = pp_sofar[0:100] + + @staticmethod + async def add_text(_1, _2, item): + global pp_sofar + pp_sofar += item.label + PassphraseMenu.check_length() + + while not isinstance(the_ux.top_of_stack(), PassphraseMenu): + the_ux.pop() + + async def done_done(self, *a): + # import to work on empty string here too. + the_ux.pop() + err = set_bip39_passphrase(pp_sofar) + await ux_dramatic_pause("Switching....", 0.25) + + def on_cancel(self): + if the_ux.pop(): + # top of stack (main top-level menu) + self.top() + +class SingleWordMenu(WordNestMenu): + 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=None) + + @staticmethod + async def all_done(new_words): + word = new_words[0] + options = [word, word[0].upper() + word[1:], word.upper()] + for w in options[:]: + options.append(' ' + w) + + return [MenuItem(w, f=PassphraseMenu.add_text) for n,w in enumerate(options)] + + def late_draw(self, dis): + #PassphraseMenu.late_draw(self, dis) + pass + +async def spinner_edit(pw): + # Allow them to pick each digit using "D-pad" + from main import dis + from display import FontTiny, FontSmall + + # Should allow full unicode, NKDN + # - but limited to what we can show in FontSmall + # - so really just ascii; not even latin-1 + # - 8-bit codepoints only + my_rng = range(32, 127) # FontSmall.code_range + symbols = b' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + + footer1 = "1=A 2=Case 3=# 4=Symbols 0=HELP" + footer2 = "Arrows then OK when DONE." + y = 20 + pw = bytearray(pw or 'A') + + pos = len(pw)-1 # which part being changed + n_visible = const(14) + ch_x = n_visible//2 + + def cycle_set(which): + for n, s in enumerate(which): + if pw[pos] == s: + try: + pw[pos] = which[n+1] + except IndexError: + pw[pos] = which[0] + break + else: + pw[pos] = which[0] + + def change(dx): + ch = pw[pos] + dx + if ch not in my_rng: + ch = (my_rng.stop-1) if dx < 0 else my_rng.start + assert ch in my_rng + pw[pos] = ch + + while 1: + dis.clear() + + for i in range(n_visible): + # calc abs position in string + ax = i + x = 4 + (13*i) + try: + ch = pw[ax] + except IndexError: + continue + if ax == pos: + dis.text(x-4, y-19, '0x%02X' % ch, FontTiny) + dis.icon(x-2, y-10, 'spin') + + if ch == 0x20: + dis.icon(x, y+11, 'space') + else: + dis.text(x, y, chr(ch) if ch in my_rng else chr(215), FontSmall) + + if 0: + wy = 6 + count = len(pw) + dis.text(-8, wy-4, "%d" % count) + dis.text(-18-(6 if count >= 10 else 0), wy, "Chars", FontTiny) + + dis.text(None, -10, footer1, FontTiny) + dis.text(None, -1, footer2, FontTiny) + dis.show() + + ch = await ux_wait_keyup('0123456789xy') + if ch == 'y': + return str(pw, 'ascii') + elif ch == 'x': + if len(pw) > 1: + # delete current char + pw = pw[0:pos] + pw[pos+1:] + if pos >= len(pw): + pos = len(pw)-1 + else: + pp = await ux_show_story("Leaving without any change. Press 2 to set password to empty string instead. X to cancel leaving.", escape='2') + if pp == 'x': continue + return None if pp == '2' else '' + + elif ch == '7': # left + pos -= 1 + if pos < 0: pos = 0 + elif ch == '9': # right + pos += 1 + if pos >= len(pw): + pw += ' ' # expand with spaces + elif ch == '5': # up + change(1) + elif ch == '8': # down + change(-1) + elif ch == '1': # alpha + cycle_set(b'AaZzMm') + elif ch == '2': # toggle case + if (pw[pos] & ~0x20) in range(65, 91): + pw[pos] ^= 0x20 + elif ch == '3': # numbers + pw[pos] = 0x30 + elif ch == '4': # symbols (all of them) + cycle_set(symbols) + elif ch == '0': # help + await ux_show_story('''\ +Use arrow keys (4789) to select letter and move around. + +1=Letters (AaZzMm) +2=Toggle Case (q vs Q) +3=Numbers (starts at zero) +4=Symbols (all of them) +X=Delete character + +To quit without changes, delete everything. +''') + # EOF