From eb77c7cbded82ecb8f1e25dde7d06b4b29452b2d Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Fri, 2 Jun 2023 11:22:33 -0400 Subject: [PATCH] login UX for Q1 --- shared/lcd_display.py | 4 +- shared/login.py | 151 ++++++++++-------------------------------- shared/ux.py | 21 +++--- shared/ux_mk4.py | 88 ++++++++++++++++++++++++ shared/ux_q1.py | 70 +++++++++++++++++++- 5 files changed, 205 insertions(+), 129 deletions(-) diff --git a/shared/lcd_display.py b/shared/lcd_display.py index 43bf82f4..d76e3f99 100644 --- a/shared/lcd_display.py +++ b/shared/lcd_display.py @@ -236,7 +236,9 @@ class Display: self.dis.fill_rect(x,y, w,h, 0x0000) def show(self): - #self.dis.show() + # used to be critical, pushing internal screen representation to device + # however, we can be more selective/auto now... + # - but might still use... idk pass # rather than clearing and redrawing, use this buffer w/ fixed parts of screen diff --git a/shared/login.py b/shared/login.py index 7f5e4433..0ab8bb04 100644 --- a/shared/login.py +++ b/shared/login.py @@ -5,12 +5,11 @@ import pincodes, version, random from glob import dis from display import FontLarge, FontTiny -from ux import PressRelease, ux_wait_keyup, ux_show_story -from utils import pretty_delay +from ux import PressRelease, ux_wait_keyup, ux_show_story, ux_show_pin from callgate import show_logout from pincodes import pa from uasyncio import sleep_ms -from charcodes import KEY_DELETE, KEY_SELECT, KEY_CANCEL +from charcodes import KEY_DELETE, KEY_SELECT, KEY_CANCEL, KEY_CLEAR MAX_PIN_PART_LEN = 6 MIN_PIN_PART_LEN = 2 @@ -41,104 +40,41 @@ class LoginUX: self.words_ok = False self.footer = None - def show_pin_randomized(self, force_draw): - # screen redraw, when we are "randomized" - - if force_draw: - dis.clear() - - # prompt - dis.text(5+3, 2, "ENTER PIN") - dis.text(5+6, 17, ('1st part' if not self.pin_prefix else '2nd part')) - - # remapped keypad - y = 2 - x = 89 - h = 16 - for i in range(0, 10, 3): - if i == 9: - dis.text(x, y, ' %s' % self.randomize[0]) - else: - dis.text(x, y, ' '.join(self.randomize[1+i:1+i+3])) - y += h - else: - # just clear what we need to: the PIN area - dis.clear_rect(0, 40, 88, 20) - - # placeholder text - msg = '[' + ('*'*len(self.pin)) + ']' - x = 40 - ((10*len(msg))//2) - dis.text(x, 40, msg, FontLarge) - - dis.show() - def show_pin(self, force_draw=False): - if self.randomize: - return self.show_pin_randomized(force_draw) - - filled = len(self.pin) - y = 27 - - if force_draw: - dis.clear() - - if not self.pin_prefix: - prompt="Enter PIN Prefix" - else: - prompt="Enter rest of PIN" - - - if self.subtitle: - dis.text(None, 0, self.subtitle) - dis.text(None, 16, prompt, FontTiny) - else: - dis.text(None, 4, prompt) - - if self.footer: - footer = self.footer - elif self.is_repeat: - footer = "CONFIRM PIN VALUE" - elif not self.pin_prefix: - footer = "X to CANCEL, or OK when DONE" - else: - footer = "X to CANCEL, or OK to CONTINUE" - - dis.text(None, -1, footer, FontTiny) - - else: - # just clear what we need to: the PIN area - dis.clear_rect(0, y, 128, 21) - - w = 18 - - # extra (empty) box after - if not filled: - dis.icon(64-(w//2), y, 'box') - else: - x = 64 - ((w*filled)//2) - # filled boxes - for idx in range(filled): - dis.icon(x, y, 'xbox') - x += w - - dis.show() + # redraw screen with prompting + ux_show_pin(dis, self.pin, self.subtitle, not self.pin_prefix, self.is_repeat, + force_draw, footer=self.footer, randomize=self.randomize) def _show_words(self): + # Show the anti-phising words, but coordinate w/ the large delay from the SE. + # - show prompt w/o any words first dis.clear() - dis.text(None, 0, "Recognize these?" if (not self.is_setting) or self.is_repeat \ - else "Write these down:") + prompt = "Recognize these?" if (not self.is_setting) or self.is_repeat \ + else "Write these down:" + dis.text(None, 2 if dis.has_lcd else 0, prompt) dis.show() + + # - show as busy for 1-2 seconds dis.busy_bar(True) words = pincodes.PinAttempt.prefix_words(self.pin.encode()) - y = 15 - x = 18 - dis.text(x, y, words[0], FontLarge) - dis.text(x, y+18, words[1], FontLarge) - - dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) + # - show rest of screen and CTA + if dis.has_lcd: + # Q1 + x = 12 + y = 4 + dis.text(x, y, words[0]) + dis.text(x, y+1, words[1]) + dis.text(None, -1, "CANCEL or SELECT to continue") + else: + # Old style + y = 15 + x = 18 + dis.text(x, y, words[0], FontLarge) + dis.text(x, y+18, words[1], FontLarge) + dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) dis.busy_bar(False) # includes a dis.show() #dis.show() @@ -146,7 +82,6 @@ class LoginUX: def cancel(self): self.reset() self.show_pin(True) - async def interact(self): # Prompt for prefix and pin. Returns string or None if the abort. @@ -162,6 +97,9 @@ class LoginUX: if self.pin: self.pin = self.pin[:-1] self.show_pin() + elif ch == KEY_CLEAR: + self.pin = '' + self.show_pin() elif ch == KEY_CANCEL: if not self.pin and self.pin_prefix: # cancel on empty 2nd-stage: start over @@ -202,13 +140,13 @@ class LoginUX: callgate.fast_wipe(False) # not reached - if nxt == 'y' or nxt == KEY_SELECT: + if nxt == KEY_SELECT: self.pin_prefix = self.pin self.pin = '' if self.randomize: self.shuffle_keys() - elif nxt == 'x' or nxt == KEY_CANCEL: + elif nxt == KEY_CANCEL: self.reset() self.show_pin(True) @@ -225,23 +163,9 @@ class LoginUX: self.show_pin() else: - # XXX other key on Q1? Ignore for now, but we will need to support - # passwords in future. + # other key on Q1? Ignore pass - async def do_delay(self): - # show # of failures and implement the delay, which could be - # very long. - dis.clear() - dis.text(None, 0, "Checking...", FontLarge) - dis.text(None, 24, 'Wait '+pretty_delay(pa.delay_required * pa.seconds_per_tick)) - dis.text(None, 40, "(%d failures)" % pa.num_fails) - - while pa.is_delay_needed(): - dis.progress_bar_show(pa.delay_achieved / pa.delay_required) - - pa.delay() - async def we_are_ewaste(self, num_fails): msg = '''After %d failed PIN attempts this Coldcard is locked forever. \ By design, there is no way to reset or recover the secure element, and its contents \ @@ -278,9 +202,7 @@ Press OK to continue, X to stop for now. self.reset() if pa.num_fails: - self.footer = '%d failures' % pa.num_fails - if version.has_608: - self.footer += ', %d tries left' % pa.attempts_left + self.footer = '%d failures, %d tries left' % (pa.num_fails, pa.attempts_left) pin = await self.interact() @@ -291,13 +213,10 @@ Press OK to continue, X to stop for now. dis.fullscreen("Wait...") pa.setup(pin) - if version.has_608 and pa.num_fails > 3: + if pa.num_fails > 3: # they are approaching brickage, so warn them each attempt await self.confirm_attempt(pa.attempts_left, pa.num_fails, pin) dis.fullscreen("Wait...") - elif pa.is_delay_needed(): - # mark 1/2 might come here, never mark3 - await self.do_delay() # do the actual login attempt now try: diff --git a/shared/ux.py b/shared/ux.py index 22587628..f88a526d 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -6,24 +6,23 @@ from uasyncio import sleep_ms from queues import QueueEmpty import utime, gc, version from utils import word_wrap -from charcodes import (KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_HOME, +from charcodes import (KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_HOME, KEY_NFC, KEY_QR, KEY_END, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_SELECT, KEY_CANCEL) DEFAULT_IDLE_TIMEOUT = const(4*3600) # (seconds) 4 hours -# see ux_mk or ux_q1 for these... - -# How many characters can we fit on each line? How many lines? +# See ux_mk or ux_q1 for some display functions now if version.has_qwerty: from lcd_display import CHARS_W, CHARS_H CH_PER_W = CHARS_W STORY_H = CHARS_H - from ux_q1 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text + from ux_q1 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin 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 + from ux_mk4 import PressRelease, ux_enter_number, ux_input_numbers, ux_input_text, ux_show_pin # This signals the need to switch from current # menu (or whatever) to show something new. The @@ -137,8 +136,7 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_es # - returns character used to get out (X or Y) # - can accept other chars to 'escape' as well. # - accepts a stream or string - from glob import dis, numpad - from display import FontLarge + from glob import dis lines = [] if title: @@ -164,9 +162,9 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_es del msg gc.collect() else: - # simple string + # simple string being shown if version.has_qwerty: - msg = msg.replace(' X ', ' CANCEL ').replace('OK', 'SELECT') + msg = msg.replace('\nX ', 'CANCEL ').replace(' X ', ' CANCEL ').replace('OK', 'SELECT') for ln in msg.split('\n'): if len(ln) > CH_PER_W: @@ -214,6 +212,9 @@ async def ux_show_story(msg, title=None, escape=None, sensitive=False, strict_es top = max(0, top-1) elif ch == '8' or ch == KEY_DOWN: top = min(len(lines)-2, top+1) + elif not strict_escape: + if ch in { KEY_NFC, KEY_QR }: + return ch diff --git a/shared/ux_mk4.py b/shared/ux_mk4.py index 049ecdef..968304b2 100644 --- a/shared/ux_mk4.py +++ b/shared/ux_mk4.py @@ -336,4 +336,92 @@ Add more characters by moving past end (right side).''' help_msg += '\nTo quit without changes, delete everything.' await ux_show_story(help_msg) + +def ux_show_pin(dis, pin, subtitle, is_first_part, is_confirmation, force_draw, + footer=None, randomize=None): + + # Draw PIN (placeholder) + from display import FontTiny, FontLarge + + if randomize: + # screen redraw, when we are "randomized" + # - only used at login, none of the other cases + + if force_draw: + dis.clear() + + # prompt + dis.text(5+3, 2, "ENTER PIN") + dis.text(5+6, 17, ('1st part' if is_first_part else '2nd part')) + + # remapped keypad + y = 2 + x = 89 + h = 16 + for i in range(0, 10, 3): + if i == 9: + dis.text(x, y, ' %s' % randomize[0]) + else: + dis.text(x, y, ' '.join(randomize[1+i:1+i+3])) + y += h + else: + # just clear what we need to: the PIN area + dis.clear_rect(0, 40, 88, 20) + + # placeholder text + msg = '[' + ('*'*len(self.pin)) + ']' + x = 40 - ((10*len(msg))//2) + dis.text(x, 40, msg, FontLarge) + + dis.show() + + return + + filled = len(pin) + y = 27 + + if force_draw: + dis.clear() + + if is_first_part: + prompt="Enter PIN Prefix" + else: + prompt="Enter rest of PIN" + + + if subtitle: + dis.text(None, 0, subtitle) + dis.text(None, 16, prompt, FontTiny) + else: + dis.text(None, 4, prompt) + + if footer: + pass + elif is_confirmation: + footer = "CONFIRM PIN VALUE" + elif is_confirmation: + footer = "X to CANCEL, or OK when DONE" + else: + footer = "X to CANCEL, or OK to CONTINUE" + + dis.text(None, -1, footer, FontTiny) + + else: + # just clear what we need to: the PIN area + dis.clear_rect(0, y, 128, 21) + + w = 18 + + # extra (empty) box after + if not filled: + dis.icon(64-(w//2), y, 'box') + else: + x = 64 - ((w*filled)//2) + # filled boxes + for idx in range(filled): + dis.icon(x, y, 'xbox') + x += w + + dis.show() + # EOF diff --git a/shared/ux_q1.py b/shared/ux_q1.py index c9e53fbe..f214530b 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -5,6 +5,7 @@ from uasyncio import sleep_ms import utime, gc from charcodes import * +from lcd_display import CHARS_W CURSOR = '█ ' @@ -93,6 +94,9 @@ async def ux_enter_number(prompt, max_value, can_cancel=False): elif ch == KEY_DELETE: if value: value = value[0:-1] + elif ch == KEY_CLEAR: + value = '' + dis.text(0, 4, ' '*CHARS_W) elif ch == KEY_CANCEL: if can_cancel: # quit if they press X on empty screen @@ -116,9 +120,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100): # - Should allow full unicode, NKDN # - but our font is mostly just ascii # - no control chars allowed either - # - TODO: editing, completion, etc + # - TODO: editing, line wrap, completion, etc + # - TODO: press QR -> do scan and use that text from glob import dis - from lcd_display import CHARS_W from ux import ux_show_story dis.clear() @@ -143,6 +147,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100): if len(value) > 0: # delete current char value = value[:-1] + elif ch == KEY_CLEAR: + value = '' + dis.text(0, 1, ' '*CHARS_W) elif ch == KEY_CANCEL: if confirm_exit: pp = await ux_show_story( @@ -152,4 +159,63 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100): elif ' ' <= ch < chr(127): value += ch +def ux_show_pin(dis, pin, subtitle, is_first_part, is_confirmation, force_draw, + footer=None, randomize=None): + + # Draw PIN during entry / reentry / changing or setting + #MAX_PIN_PART_LEN = 6 + + # extra (empty) box after + ln = len(pin) + FILLED = '◉' + EMPTY = '◯' #, '◌' + msg = ''.join(FILLED if n < ln else EMPTY for n in range(6)) + y = 1 if randomize else 2 + + if force_draw: + dis.clear() + + if randomize and force_draw: + # screen redraw, when we are "randomized" + # - only used at login, none of the other cases + # - test w/ "simulator.py --q1 -g --eff --set rngk=1" + + # remapped numbers along bottom + x = 3 + dis.text(x-1, -4, ' 1 2 3 4 5 6 7 8 9 0 ', invert=1) + dis.text(x , -3, ' '.join(randomize[1:]) + ' ' + randomize[0]) + + if force_draw: + + if is_first_part: + prompt="Enter PIN prefix" + else: + prompt="Enter second part of PIN" + + + if subtitle: + # "New Main PIN" ... so not really a SUB title. + dis.text(None, 0, subtitle) + dis.text(None, y, prompt) + else: + dis.text(None, y, prompt) + + if footer: + # ie. '1 failures, 12 tries left' + dis.text(None, -2, footer) + + if is_confirmation: + cta = "Confirm pin value" + if is_confirmation: + cta = "CANCEL or SELECT when done" + else: + cta = "CANCEL or SELECT to continue" + + dis.text(None, -1, cta) + + # auto-center broken w/ double-wides + dis.text(10, y+2, msg) + dis.show() + + # EOF